From 1b2e48eb2575bb8b78336df7eb6a24fd2452187c Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Tue, 17 Mar 2026 19:15:35 +0300 Subject: [PATCH 01/49] vector link docker added --- src/backend/docker-compose.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/backend/docker-compose.yml b/src/backend/docker-compose.yml index ff76b6f5..cff2d8be 100755 --- a/src/backend/docker-compose.yml +++ b/src/backend/docker-compose.yml @@ -4,7 +4,6 @@ services: terminusdb: image: terminusdb/terminusdb-server:latest container_name: terminusdb-server - pull_policy: always ports: - "6363:6363" environment: @@ -15,19 +14,19 @@ services: volumes: - terminusdb_storage:/app/terminusdb/storage healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6363/ok"] + test: ["CMD", "curl", "-f", "http://localhost:6363/api/ok"] interval: 10s timeout: 5s retries: 5 semantic_indexer: - image: terminusdb/vectorlink:latest + image: terminusdb/vectorlink container_name: terminusdb-semantic-indexer ports: - "8080:8080" - depends_on: - terminusdb: - condition: service_healthy + # depends_on: + # terminusdb: + # condition: service_healthy environment: - TERMINUSDB_CONTENT_ENDPOINT=http://terminusdb:6363 - TERMINUSDB_USER_FORWARD_HEADER=X-User-Forward From ac10ee47a2510dbf88a3d78fd0a674eefaaa5bcd Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 00:40:04 +0300 Subject: [PATCH 02/49] schema cleanup --- src/backend/app/api/v1/play_ground_routes.py | 14 -------------- .../core/model/schemas/code_element_schema.py | 2 -- .../app/core/services/play_ground_service.py | 14 -------------- src/backend/vector_storage/admin%2Fsir.vecs | Bin 0 -> 36864 bytes ...n%2Fsir@1dagck3uovy24coiasr81il63rdrrq3.hnsw | 1 + .../vector_storage/api%2Fdb%2Fadmin%2Fsir.vecs | 0 ...t%2Fadmin%2Fsir%2Flocal%2Fbranch%2Fmain.vecs | 0 .../api%2Fdocument%2Fadmin%2Fsir.vecs | 0 8 files changed, 1 insertion(+), 30 deletions(-) create mode 100644 src/backend/vector_storage/admin%2Fsir.vecs create mode 100644 src/backend/vector_storage/admin%2Fsir@1dagck3uovy24coiasr81il63rdrrq3.hnsw create mode 100644 src/backend/vector_storage/api%2Fdb%2Fadmin%2Fsir.vecs create mode 100644 src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir%2Flocal%2Fbranch%2Fmain.vecs create mode 100644 src/backend/vector_storage/api%2Fdocument%2Fadmin%2Fsir.vecs diff --git a/src/backend/app/api/v1/play_ground_routes.py b/src/backend/app/api/v1/play_ground_routes.py index ea8ca26e..0b2b19f6 100644 --- a/src/backend/app/api/v1/play_ground_routes.py +++ b/src/backend/app/api/v1/play_ground_routes.py @@ -18,8 +18,6 @@ class PlayGroundResponse(BaseModel): relative_path: str code: str executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None owner_function: Optional[str] = None owner_class: Optional[str] = None @@ -35,8 +33,6 @@ class CreatePlayGroundRequest(BaseModel): relative_path: str = Field(..., min_length=1) code: str = Field(..., min_length=1) executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None owner_function: Optional[str] = None owner_class: Optional[str] = None @@ -50,8 +46,6 @@ class UpdatePlayGroundRequest(BaseModel): relative_path: Optional[str] = Field(default=None, min_length=1) code: Optional[str] = Field(default=None, min_length=1) executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None @@ -82,8 +76,6 @@ def _to_response(raw: dict) -> PlayGroundResponse: relative_path=raw.get("relative_path", ""), code=raw.get("code", ""), executable_path=raw.get("executable_path"), - examples_path=raw.get("examples_path"), - command_prefix=raw.get("command_prefix"), filename=raw.get("filename"), owner_function=raw.get("owner_function"), owner_class=raw.get("owner_class"), @@ -125,8 +117,6 @@ async def create_playground( relative_path=request.relative_path, code=request.code, executable_path=request.executable_path, - examples_path=request.examples_path, - command_prefix=request.command_prefix, filename=request.filename, owner_function=request.owner_function, owner_class=request.owner_class, @@ -173,8 +163,6 @@ async def update_playground( and request.relative_path is None and request.code is None and request.executable_path is None - and request.examples_path is None - and request.command_prefix is None and request.filename is None ): raise HTTPException( @@ -189,8 +177,6 @@ async def update_playground( relative_path=request.relative_path, code=request.code, executable_path=request.executable_path, - examples_path=request.examples_path, - command_prefix=request.command_prefix, filename=request.filename, ) if not updated: diff --git a/src/backend/app/core/model/schemas/code_element_schema.py b/src/backend/app/core/model/schemas/code_element_schema.py index 08cbf8c8..a006e613 100644 --- a/src/backend/app/core/model/schemas/code_element_schema.py +++ b/src/backend/app/core/model/schemas/code_element_schema.py @@ -12,8 +12,6 @@ class PlayGroundSchema(BaseSchema): description: str relative_path: str executable_path: Optional[str] = None - examples_path: Optional[str] = None - command_prefix: Optional[str] = None filename: Optional[str] = None code: str owner_function: Optional[str] = None diff --git a/src/backend/app/core/services/play_ground_service.py b/src/backend/app/core/services/play_ground_service.py index 40a8dcef..d0edd095 100644 --- a/src/backend/app/core/services/play_ground_service.py +++ b/src/backend/app/core/services/play_ground_service.py @@ -35,8 +35,6 @@ async def create_playground( relative_path: str, code: str, executable_path: Optional[str] = None, - examples_path: Optional[str] = None, - command_prefix: Optional[str] = None, filename: Optional[str] = None, owner_function: Optional[str] = None, owner_class: Optional[str] = None, @@ -62,8 +60,6 @@ async def create_playground( relative_path=relative_path, code=code, executable_path=executable_path, - examples_path=examples_path, - command_prefix=command_prefix, filename=filename, owner_function=owner_function, owner_class=owner_class, @@ -88,8 +84,6 @@ async def update_playground( relative_path: Optional[str] = None, code: Optional[str] = None, executable_path: Optional[str] = None, - examples_path: Optional[str] = None, - command_prefix: Optional[str] = None, filename: Optional[str] = None, ) -> Optional[dict]: existing = await self.repos.play_ground_repo.get_by_id(playground_id) @@ -110,12 +104,6 @@ async def update_playground( executable_path=existing.get("executable_path") if executable_path is None else executable_path, - examples_path=existing.get("examples_path") - if examples_path is None - else examples_path, - command_prefix=existing.get("command_prefix") - if command_prefix is None - else command_prefix, filename=existing.get("filename") if filename is None else filename, owner_function=existing.get("owner_function"), owner_class=existing.get("owner_class"), @@ -161,7 +149,5 @@ async def run_code(self, playground_id: str) -> CodeResponse: project_root_path=project_path, python_executable=playground.get("executable_path"), code=playground.get("code", ""), - examples_path=playground.get("examples_path"), - command_prefix=playground.get("command_prefix"), filename=playground.get("filename"), ) diff --git a/src/backend/vector_storage/admin%2Fsir.vecs b/src/backend/vector_storage/admin%2Fsir.vecs new file mode 100644 index 0000000000000000000000000000000000000000..e1339607a8fc41d7b70c59438185267248a01211 GIT binary patch literal 36864 zcmW)ocU({Z7snGK4M{^uB$81?Qulo>$&Acor>|A^NMtKYl#obDB9SztP~G>rlB|SC zW=3Y&BQxuJ-u>}?_{FEsz4yG&dA**m)2HA3hTHhw7CZCVAbalUQy)7udBQ@{{^C5N-q5=80o$-$Q@-}XS~a?O4CVe; zQ2wmV0vG*dSCb5+!bgpGtMY9uWoCPDSZpNgDy0}YBof~kcf}jCs`+BKN3g8I7C!bH zfnRFB^UhO8L-MDlaO~zcm^-wAS}*GcfBob#PN{y&E6po$WX4-&JL3dPX}iP4_*Zir zFschLF8ha}6A)KSd52z`hoF}GUPjLX_B+b?#SMuJPPdnpYXg{NLb1!n>p76rtD#)J z=QSJKYZI=kZVZ`CR7l=96D^*d=IhNOaOH^JI9h8Xi~RM8^+`8k=cZnPi2-+DaQR2Y ztxYHXD#I6=+Rub{R==TA=Q#Yh+!(AP^4O6*o8i_fU7+WO$X|<*&H;YtS%@0pm)sdR zyXRkQwB;CUR{a5-O}enV>080$TMw01#HCgY}h_V zhxL4sDor}VWKp+b`6%{pbqSKL+=oV`ZV+NZy)~-|x?Dd6iSgsXdec~FWiuMT$Fu|6 zZX?;DKa;_7Xc#mesPODA1}dCBq*Qt@LF<|z>M0L-+$Tfe1t#i$(U&CCAz!HWt5IqY z4}S}vD&6F6Fyxe>?9z4xJ749G!#dd(9PoN3-A_%I&ZhKLv$IcO{SL+SnjK*3AHl2# z6tOeEXF}Htd00Q=16(~`02cc@W9u6k*d$X^yN(p)+B5 z)mL5=YpBk)83BLSmeR9Lgl5$X(RJx?7^Kq+QWt;5s8$2mkfF0+$H6z!PVaBff(>BQ z|2V(lHbym-n`oSeFGo&tn|2$Z_v~P{uJc|V)3{9Pvdam(+%1Oio^h<^{ZXizvXRg1 z(j2aS87=D1q_N(xzZBs4*$MQ$^<{b%o^vov+%tF0n+j`WJAAo%2I_2_i*pkO@-__( zVSe0m?6R>DKl^$N3$~S%Jyp$+UI$BO90&b?`|L%|QOPJ|FI>F!1e&PFl@Yf#;Lx*= z*|)_Xm`0T;Zkw)PvD^vU{ksYSN%4GYt>F~}?4X|yq$GqR+k*Mjk9QNMQ zknM`&kY)%+?NhP4`yafZYYRR;&vEvCTlM`w2bg~DG8fkiY^sM7W`$$id}nTQ^Ed12 z;lo|KbI1rW5&gq6%;w=f!cDMKV>tJF2T!A zZDIXnOFXhiTeaWN9@F&_mB%kvVONjG*e87_>IMRaK7Ptu*_83J$X0ShdX)5KW?Q8B zsjDMtE7G%L$#+LI&I_XJWiTJTB)+#pKOl``-qS9i-s1+4IqbTN-IR}v=3k*1fvTj1PO4^}?N7Ce%Jf$o`VH5%wXv3-wh zcz8ck)LGVa`oVww3dg2LPO~d#7C0NUK1HvMV_|#d%g2W-06Iq)^<*Sc|G~4Lt?~7~ zTV<*FGiwsW<{3y@b%4LEUq*=J^%=FQMXXA4YYN z`h=_z&+Y7(6vNHBm;h-Q&M|ERwPv%K=JTF-&svL(ZMqojrYr(+?KMGXur$#g4K2n{ z%`EY#rxCt&Zw`s$2gn0Mw=>K1!Lwa4(*l(2rl-F&t=qNJGpquAml4&(W`=1B_kS5@wmNhf=e~DEyV~UH#*~9y`1P z>=+x1t!BSqvp&xeXN<>xq@jwA(3|&zLy9|)mtegqO_B78kJE4yx`vxiw-x=%E*D*g z1%3?3gG3-7$AE5ErD?-F(MPW>l2(G?7YF-b2wb#Q((a|hhgBKD`}+GJ+EtLv-e(em3zvvB;zYV^&{=RzlT zuX9J8PsThUV>W7OZN_h{Gni}M6Q+5ftQDfmJ zd?q^!Wo>JCOY=}@y)_QB#+OKghhG+ctrFkm^?NlP+#7pA-_-81;Ln&-+ZcH(dwj=( z51Y^Fp0ZHSVUXHrjRxszh(gZ<#6~c}=_}@0zefLMZt!&d5hPz^r1wg-(?U2guaMvV zTwmV4+Y;xbFNNsPNPexCFPrfr6YY15!OqDuVQ|z#N$-6RnRJ|3c0In{))=~fmzeD% zJ)A#o6ogIy9`V-`^=GDm??M~2OmN4jlJ!_MMN97RdoEJnpi|-rCU~|qF_V9(4Ftg) zbcV8xpNaIxUsEP;mPu>55|Pcs?Dq9dRD?&@hBTM&=T)%&yANPs(?~`>z=+pb_-8F< zc+VAk&#njef4zrey$`VI!MR8*tfXDmQ5}6Y@WFlJ+2Qwg`J4L}BXK7#)XcymwJY<> z8yF+?Qyl}+y^!X`3fK|8-P}nFS-TUfUaTmmvlibN)2jXPk{)bG1c}$%6F+lN`Ib44**` zJN<8vO56g(Ye?*kY4;Rj_fQ;O^8{O4f5hPj60ybFyU=@iH`2{$#p7E)nCQ@-^e`9l zjX0}5V1`SrEQwLJ6N_i!$z$a(#d9|nF7XEPAjvVwMDPa}ye~3Pn{i|ChRI>YK z9o+uB0m(OUU1tkf__M*{<;-gJW;`@2j4!WRipxj0p**KY%u}Qc{@sWPW}RBF$n#`@d$40e#F7gG? zd;<9afB4FqH10pv;bZ#F-N#U2x+7vEHg^bazk&vU2AR-#@&3@(ul zLd$s@-|B8!RJNA0Z$2;0^%1Mm! z6Q>-_-)=b!hXS1hyD{=PdFxv@;)3Hyuc^z;DYktc6{y{6Eik!X8D^au1O+|PxnQaFW;#d=%3c-(a+-BwT}x=&%%@Hp zI=n-Law+Rnkye-2M{Uyw;wDx2CixTYSzVE7FHCSiuVQ4 zV|4Ds8@=&H+Ak)~Shd3DR!qAbLA8X_G3R^N%{`qXF-HTnMXwCMap`Nw2#+@@*=go(D`K*2icbU8y zX4{l&B1D7>3?Bm8dP88D`_kJuX^ zc@qjwD@t{ zg>+w9zN-h=yBZ?>J@lM=o$A^IEhnC0#K7cRc}UlVc&E=&!LmL`8V{6j^Mv0EEoob@ zm&Y!OBWCLahaC#@^D1u(zQH@okCLAUAzi<2SM_GR8*YsYV&rdi`wz-de4h0?G5=JT zB>rsZQ=q)dDL3;Q6I>LlYbES=@NpPkGnMBJ4aP%(r$A`P*_1+|`;6v*(f$TIS1qTV z)gexG<0%2<_(N|mrarL1-n>ZZ^C1QHtoDOQ-@SEvk1J~`7!FYRlA|_2dJ18M4dlqB zC0KvX1LnTo9{RT$3{*o#Jf)Iu$jf4lg@%g0P$?h6Mb|AzJuf{vSe>7p?Id!XV(=gd z|1E4onYRe$nC{2t>juJ;KD+plH$xfiL-_f-{ZM3^J>SlNmXq>BCB6ymdNJWDC%HpM%8SvM?M3WZto&hbI!FFENl&hj>^EA$+%87~1LD%OBMvK)gxyY9qKw)RfbE zIL+)5P5jgjqDnMzQHd`jo#kTRDEhH%NE3FU9A{LEL#lf#^@l zVvSL7kl-`g0dQgvE;yL7FA}%XGuqJo<#5`4=F`qPkM?cs&G@re8QKJP>Nuj972-7L z`zwHE|GvooVxPqH+qS?d_f#ORLFc`rk+MhK{@X3CeqDdl{y}g&%(I@4l(&KQ46@j# z9PpZswAbY#Q;D-9t^vxKf{{7#K9G0R<=wQH4yu^Vj;|-8M%8Z0UYcSz$;saZL+5|& zS1QpSORy3w@r#B6zwJ1&?Jk9OVeIPhKJei1ko>t@cZ<1{8=r|*#ICKIoJAQk3IeB& z;0Du*>*kCy3Lo*jyV#?m$mp}YcZnJkJNH)U_3%-(Rm8l;KkuqKly<}Liy>0i>T<=! zr3X6B4#M2DbO^gR47UwQW4En(p+)Djw8Qj**WS8N-nyau>t8c;{}RnzMsH=FwUsP< z*ERMi*+{-RXEFO)@;QIdmk02}!;e>t8>I|?XoZUwxUlfdj*xe*ky^Daf|)O~fRwhH za!IhcYH;^B(zP-A#4>&<#|-|Mw`Pm@d1eun%A9%y!~4wNkZF^EK5=VMXVFx2*;v4x zl|?Xd&1o3acQ(HD>5X#RzR+o=J$qVs6WFak7@nER4I&QVJ-(6u3DQ#k_@%MZ>rJ7n zpDFVk^iC1~ZanEOdR{n&6}OwKAJT)dJU@YXJwed7*2Qq6D9PuAzMR_55!lW3`JRL3 zgF&)}Tf_Z{Ig+;582B@MBs1u3COak^ zhkGArpEfVdWrc1s^gd8uHahYgd^H=&^tE$s&jz8%KJOoLAs^&x3v5b8HMuK09s z33KONM<aBk~xfq8Z@m2YU(5NNzbrHd6Lw=GeqW;i@;F8*8Z40OJj zJEe_W@o6?tU0~whRXpv?U)Fy~HJj?Y0exFlKu~|mP7keEQlrz5{qPGoh3H}U;IphS z#+td^Nnn=eqhRiv9c)wO5pdtyg(q$Lr9351`)1Mrew1`XH@P9knD2rYlMXT+YcEJ> z=mn;3CzO$0D!JQYC%IF$#8SrQ;EI|{V9@?F)hHBv)^z~7cO2etGdT6C2N%qS!0SiF ze4@J^WK9eO$NFY;t#oj{ITVxe8QuxAM8;-fr?s0gYNoTCc5ed6n`~7bl8UF3-a=G% ze;i~J4TF}n2G{V3qMzjz1-o5*ot$v$Fxvuu>&J}xn)(3L23n8N_f;yqa2pIYL;kXf zzctv>+nXWue7xvEs(FU!4NlLEgYIdm1(%ZOjMk%p?lYXKv6lyZpU%B5J%ODbJM*b# zsI0I9>OG)ygoIuZ_%FUWbnQ76Vy-q;J9*q>C;LT1!VPQjwd~SR6U5vk-Q2=N|Igp= z4F85VVt;}*Lip+>@Mzp7=K8TO(0t-t&#yo;3sx(8!M5l9aISM}a6GjI3y=R~iEq2X zikiW&s<2o({re_y;UgvQTo@Yanxji%99(sBrCINS>8Cd!{cojaaDA?|-(KCYd_42o znZ@aKFmeFnxhuXgkFz_$^FmkHRB7w-D0wHi+n$H4FRjopU^PCAY%9)6p;>ZyAAK0j zybI}lGGV@FI*!~i2C;KB7WNuBQ1-?GYRzYH>JKh-Cbm=oJCATivkQf$Q$>BzRlX+Cb)^QaB^=c( z9A{kHiz6>A=Wmyr@xX|#(mgl_z9X6;?o@aK&$-yeyj+@_|9}sl@>tv>@@_FwLh}e9UNe8SueDHI+A%T-2}M<&NJ)PvvJj=r_i-t7B?ukP1=4HjgHK~ z@UDKmJj{>N*|V)Bv+%apESJWx0z0*6D@V=D!-UaKIB7I%?jHIhb+M=+K}?% z5Ord=YzQ?xf^VZga4{1x@uT7GqaZOmLdW=cdkyu)gGy0vmb7UVxFni`Q^TRKKQS4~ zMum_cq(VTlC+IJV<(j$sA%1ofr0;{8u16rgS2~rpu_rPwr%YLe(BmmAgyI| zCUV)l`;0sT+zwRh6KgU8Xb6TYbMBgkNkRYrT`Of%7gV{uFcM_IU}L zKd%uMt{IFqCd077yn(1UPR0Q`8?mG+n8{=P89j^S`QIh@TH;T$atvM@1Oa_NB+g#| zD<)*2RrC%l-|xoW{=5wX4(($e{YJx|>S~3)m&*%>Im_C<5Y#aaKXqNqEbYEXq&wXD zS7&@>xC+F%8VBTXZL5Qre5-d}>}*e<`AT919yLyOSZbv-1H zsRyfFw}5SnxttyFfXUsTW9>`IUhl_?e&p80U!|a<<50&d0{uT^V2HHDW&&FVpz77I)=S!Wk74(``i&*qPDWsnEz~=t-xbSAdU8JX^8Q;(`WGviy*HFwQ41M?-{?t2% zIZF+(@N&6J`yq3H^aeuH&nSX{NPlIyZ6S;CJHWm=+?S}=RnmN3w$DJ+jXkT;hKB{O z>D)DCr^M+UMPiT0HojiA)S$BOX|dIZe}G?StE_>cMVIYGbBO zclyUje_5K1FGTKYC|`ZmgOh(K7X1^r@XHHVR8BsHu@-~Wv_rN?U%OO&pD$TPS12wG z*JJGLn>atex#AWch3)@%yO1X_nlErjs8vX#LCb0$x{mD#RYk}XZX9HSD~JhE^fdJk zw|m)^w5Frvx#A$s8r2Vom7!o7Lx+Twu(ickse5$3lfI-wvFPg0Edh>#O7~GGi|o>7h*LC==f?>JRpB_CqEU(R!r10RE>fmxitd z_lmr_9`gTS$Ba94$O~F_m=U|;_-PK%;LRpLdrzPlz_de0T&$-ySL@DTcq3d}^h>ER zxr&vpK3p)%hV##rsv^L|jqY;tm|*y^Hi2#E>;X=Br;%y{4YYii_i-C()|(2!b~t0! zNhGa<*GF5>+-s>+YqWTD5;k~i%A}oYVwlguRQ6D<54j)DH= zyu?vo_MQ<3*EeiHVr`W;E5CArg2WBziUy4O9Am4ERnl*~6g-`gPYV4;(h4Qv?FuyO z|B_KJ!V1@0=7R=y$`u{yi7Bv7T7mYKm3fuSDkM| zYP%DnSMqIV2G(&9WdOK(QWG~s(XMGxTTn6{DOCpUX!A6TJZ}g3-yTien8;o~a;JF= zRs>s=1jpgky(!$u<+tM598gQTC>Rk1fBRhNfyDcQ(duUSYO5EVaup^i4m3vt>HVkh z+8+Ioe4PjOK8RsP_mMIToD4iC*iY`iWR^r+tps&6$4r}6^emy=tJV}uA6((9j=Trr zX2GS-#IEEemU0Tlap7g&rdyG+6$XBa;kT`FfjEItFLU>^4|&Si9-^i|XU_~H?|}Q4 z=B%jud))al9pi4y6Ms6--KT|`4Kkp#L0=pf^AydCZ1M7|)nGYr1U!pO;=Z{{>a^a@ zqY0ANg7@*mNSqJE8m!aHH)yI~AM}#l@yGKJOg;1oJo=SEf@QZlFIFBKu?_t@pzxXK ztE1T4M;g$;*%+pen25v;{8MHQ`9=bEGt!T>9S|AvBPa*Fr_IcW#8^d5C{D;Vmv_ZE>y(a?1$Px)BF z%UTbw^9||)Wmbb+M%p3zhj?KmI9b{NWon7|M;84N_{l_^0WSRT4Yy1$Wt45z;M^5k2mx##0>Fq~k6)Mr51O!aT9U8g%W(Bha(f&K5=8QLO#VVylA1;6uZKT$f+2Z+yp$fPl1>d z8L(}4t}=gr9&t!DBeoEk27EMTg2=`~PcOWP1)bZMB-%^lr-sLiObP~{bV*MeBGnM- zy?N-*D~#?710sGfp<$HcUDEpWRf$O?+Kb?zzOS&rxGQ(Ckb&|GpT74c3JM;2Vd&W$@x`(gxc2LDMX;vZ_(=+78~BwSimBsg;ve%w_TnqA%hSZNkT4e@{d9e` z)62P_om7dAr*vH?dvo%4nX)-gx~Z$Kj*JEJc^6_$^4W)Z;79z}K#yOx-G67|3^$n3j>|2s9C%eNfm zt0P*%wmY@~IPI_?%ApZxmz$^t$wByO*In9I zH3K>u+NHLZ%C(O24gFKled#FPCbJsotdMpPwEH;6Um5=5-j{=cyadBm7S=H}X(J}O z$YO_tUCnh=IvZmDU_MpDf^_H`E9?e}LpF!WA=a?$!ET}&@NB%iTi_d-x#W* zH%L#=a(P2l^c{I75~~Q`5i^FDN6dhQUXO$>@X}TmT*(5P;fXxCQfiBv!M)b$gH``z}(lF^<5-kv|yOUxLtAmy!3;CkmJuR8ifoVhA8 zoJCYe^8QBT@#bQ0z)l~#DAD<$$Sjw9BSq~{WC7YIp=G)gf7*dzqx{V{H?%YPYBcT0 z0;Gx0+KXpU+-(PltSJ-6%jB6{>}-5nO+vwohPrWRYOt0$w;50S2u=J~dQQ1ucA0iM z8c_J`7#A8ppieH0+;I^^e^BlfOs-OY!-s+9e17>W);?<#cbc1x^+I0@O=si@G$Tq~ z7JR?5gwEecHGfba94!}OkU<|HjlxImN5i%(L-ZXH1+(W^iv1qA>69wO(p-4A*!vNi zaj`e0+=Dg6XK+!N5z@|$b}CUY?iqdluloY<=^l@vm5V`Wg~)NQ4K!rZMoc+afb^$K zvq0Xxl=wW9H_F$hpH)8N!t49nJm9MXRB#(#!P9lNbJBUfrT7#x`nrgA&>C1_y`K^H zvl+qLkuotHmd6XtRxQ&1Nt7Xk-r>pZiNc3nXx{><5JyG?8c{<-{Q^Q z4dM}<4k?F>#^C7Uk$9kcBsg1y;GUvVC0S!3j{7}@uhrZK{Ue{V3kI5U_gmYsTZag& z{LcXP>MUacdxI5yoqDou-c^_B;RE=oTbH2nFMj_-dem^6B}wEV{wFj$?;UfT6xr@oL}=cI9C=w!mwv%WwXa@0b;cadBlBzt{_xu3Lrly?DW3J=<2a1L~V{*34u8 zPg$WMzsI}M{JtGw%eetCscAELa;gI?*yo4hH8mf6;qc6sn7u;AwS6>HaUY#?&6%si z1lZuOFS*4ul_n>rHTYb~(dcKC&b-3xgna}DK3!C%nsTmuhkgsnE zgNmEOf)-uWiNSvO!)Kh7U%rc;fuWyu4tIe{rAPM>QcV|kdFhe{@(-Uo*iqXU;^Ia# z-_3@&Fxvx*nw?}G1qDdgm+zQghd#UW;5VO!o#org*+3&0HlY%aIhe{3evQ<9J@_s;Z;rQ8yD-0(*ICfJP`>5ddDt=QyR>>t3piv{&NC0rS3(b$veIe!?8m)$ z(IedIPCh%4v6j!lHllCnbz_uSH}aI^l|AI2?}Ef@(dW}0ST-aKmR)y)&^9|+!tHwK z-|H6aG+DzE2OWab2l^{x6ZbpMn_~mwYjxxw_>$Qvw2 zIXc0@5BBPhy)JB?-8uM=bPxVshQK+~r6;xBz*PAL<6H_^6aQ!oeBPm;M753i7HpaMC(-M+Kzk*=~VL<1p#H{KF@o&t)BDpifc09&%BHKz? zqiUe*cEYfI*odxz6Gin{Uqi8tWti5~dzd0S|4twM4f=*Z0u zJ>q+cMu3*dCT3@Qn$e8msS$c`d7hqpAx6b%E^FEJiyfqTvwNbcBg2@y276MX9kdlHNY{S z;&_y@82`3uDzuH$oWhNmS{J$(nnzv8t_g;byP9HJY$`r`(E^tYm7w|EuCVW$Hw>Qc zh_T0BOC!f;@JG%UAjru_n&_+#|CD1q`1(H#4zuC(@7%)A-DR_81+Md%j2CNuDJynl zaCa$}TX}zl>ZLRB`=)Yk6S9SuR)1mr<~K&F2TmE+i;X0YIoIACUjH`?*H7sP&8N&| zuQp%C%*s;cIPe5(c>WC2eda{3iGwK*{rQ#s*HGVdIXv8VNSc;&7Q?RXk%sL43XPne zgW6vUJC1(O8ukf=Gb1i@dT(afsS}ise<7Lo*d%HLL#t5{^YYQEA$E9jne{DL&!;}< zBYjz807Ccs)f6di6Ra`7r?vcL4ee@duJWSYi^NPp!SX?h-kNbhp1=!jhN+~hVDxq; zYV9pz9kq8M#Li_wS=MQG1GJAIZ z_g*M#mrVMYryLDA2akR|L+T6YRf15}zzyVA*<5JOi|Bp4f8-1)Fl7Lc_LIg0$_)a3 zveLwO*uecTt8pCs(LIVGr5^aB?N{(tO=P+*>bBVfR0~|3a00rH+{7{l9%CkPaqOO3 z99DnmsPsJe8FOt6@U|L7pF1Cit$kyl<<%pK#S=ZCGlUM4n&OVZErrJ@A9hrfEfv&I*m+F>KKT4fLS%@XLoI>GDv&7?J* zFHo$HGk8=3(->$p9-aif<%Z`j<43#INPQ3EmRUpi z_7G^Fp^eWm9!mOcN9s?U)HI4mPVq&bxfbmGx@Fwp{2Cl11z^>_r(nM#6w0dlvXU*Y zcw5_GH1fYL9sA-46RIv?V?94c+V1@GorXLvCln@3jlf-g=Xlzri;Czkx_7|G_arOt zrHr&lPR%HEAx(#-vkG|Qb9(aiWtO-(KNw2e-+`fN6Pa<*ZrHs|pZS)(QI1SriH2db z1gA(s(?}B_u}P7O&D9qY=|42fnh7U5^h5nI6S47-V%XJb8Z^LKhMV=JMdwiY8sppV4z5_>Yp9a!#R`5B3XEin_JuTr! znp+`#4}AI&hEzus`ax$S+0AUD%7$5-cm=)H7{MLlK7e#U@e5j9=LwHDN7Cz$5!c<6 z1dHfj+Xs<%kCKOs#q{o%;oJKhPQ9mW>D>g#Gq|h6J^tM74jLWn4#TUop+qZFa2Z${ zmtta*er$!;P@(H!(B~}NF(2td-XP{x_V48dG*4icq$v?IsOH}ep5O(S zvG!bjIpypGY?k!`MPL5@agL|_Y*KE`yG(jClk_K)cf68KGioNz zz~yA&Gkjq-kzJ#ofvmdlKA)aTo?`B$RJG{9oF5;61z!57JNq;9nY$PCuUSezv-V+< zwt?#UZ4!Nap<&qy!XIQ z>`&=M7xVlg@{LzWXT%HWGXv!5NZQDOdz_B~}k^(L`Jf9%)oFc))p@L;z*dNwxOK2Ql8a|_w)7ocA5i)Xf^#`)eq;mIXRVNYOTIhT6v$iTURx*115esZV)lu- z72*~#cf>Exg!Tb>ry7`Yo(=Cdo&VV}6LOkAlALFHqr=}w$Q$?;^w!*AFCGNqM)#A!KhjAn!C^gb za_Vtj{UH_d&SbHXElZH{0?f>AiuNlSs-*Ya`rif!*jrDYG4&F5s@#oH*KV_~rVeoN zXcFvfq6HD}&PqKe?!i@Sb|K9zzN+6;-an}c_?FGZo6g$mV(Uf9Ed6-=7VtrMt}?1? z7Iv_-Q#XE(Az$jprY3rGVj}kXev|~UZ;%)rs5ba0gAR~i zBlVBEa&{I(eZPs~`KLE%qCTt$qx&OGX`thvy`z5{xbr>8RF z4>5N*cz+BRTu85z9%a)`)2uDZhTU)}K6FW#tm5Yu&oR&{9Ef2_YYpUflik>qak&b4 zA(m{J&A+(X*X5iW;Tv$*lg9Gc>YwngturzI9nvBlnVt`wA6xO@A^pkQhaj;(Cx3>8 zOJ8%NW7(YUpKV^?!pqA#0~~eAvx)vd^b+Y%u*k;)J8m z72;U7jDAPDf1^|s`u~Zy=a!=#)GPakBk_uhuSXV&o?YscgftT{{AdT^`)Kym6Ua zBkPmz)q{!7ThZm;AEvn`4wK~@ob-mBihT*hAG|iqiitXY-!w*fShbdZj_jofFWRU$ z!t`O&ai`~F(wD=${kNx#dQ~At&U^Rb78BX!&d#0GtEaihY){l{j4~HeZ%H)Uqy_Jp zchCuT*w#g+o)uhL$2(GF3T5W6jJ!f6Uk7>?_A7p)^xI#X`~9~In;x=K>703M`u#D| zPG}LTDYtB*Q^&fux>j@EcpTq9DI8aiiIs;<_z&D-LKX6LCh{%mvw}6farTQ=K>UF8 zEYLmHUiA;i<w2&MqK5B_rrC=Ma+sMYo#yI8Y|QywMl&4LX*yP&3P9_uwA7>J?y zq_Rvr@b##&cko>%b_3%L&H-ta;!xOGI>~I=opIe^OW1U-9|btoED1ggS8mv^WNJ~cxvT;+ldWwohI#~&qBP6W~U50tCUwbX)#tWeL=ia4@H(UUp*DRnjXjT z!)(~x>kU=PR!aI;KW@4>0cq#K2h6CFNbBmnXDNE4iMR9VFw-SKxe;EB1!(f)8mw~7 zLE>g0|L2-(%<2A~;el1Y(5rE46g?t5=;+@~Z18>)+L35c=2)d{sdoe@$BX`lyK{HD z5Vr%(g+y9`g83rp=colIQ`yQc*XnX=;?@5ICkb6uR?hAw5ohIk4WEE-0Kqd@m?IT91P z?DX{EABJeE#MLx=D^T?F@cHM7pNi_(zJLD+pbW|PEov)rt~|}I5vONWX^$iL4S!}? zs+6n6o=GA0QpCQ9^ij1LbQEi5cNVkET+dd%Ctv828=6nh@*Qyxdz zxRc17V&4UHJ(NbC2daTgo(h6FDepiIkLNT)4Pi??1Z*yRpnx*($NR@H)23ufHs~o9+`ZMgt!?4-~nLe+yU$lwFC5 zzY3k8`aj_0T`=HL8mH$%i%ztQ8k2ydx4aj;0kq?ArX3CwS}FD=l!N6q@*WiYPkRA= zX2cNMGqi>&@tNA zHCMWhY%H6#wub%5&-e$uHev=i&9u;EPIH6gy`uNgWyM*@&>lw2KMsP=HC7dea$Nee z52IP(LOZGdaBN~5-t68}*6~Vrno$F>6Jq^NO(jopgVYXMBIgog4uoMbXJO={YVwp6 zPS<9JVbv_o$dpn2vC+FG^24gXT+F(Ocb-c59$@)4krVOFaU+E?4i7y%g167uj5Z;< z=(xcI?mV22t@5^$f5ebKzse(>L;JH+u-;*jOSaNH_o1vjLhQVFyFKk_xA7M&{EF#! zkXmBl{wN@g;UafYentb$O8#(aKPGm1t?Ip2DCddX1sYx>`M>W`FyKHj+>59I_&Y?@ z0ZA`t-cB;gQh0v-cz9;-&xL=|?u8%AE#vfB$@KeOSf+P~na%nwaxu~_lK=MKO#K`o z{FAh|1&lbog`On_wC){(D-W}Pc3O;Pm>={VPWu!O!N8odE2Er_l+BR(7vJ9a$jS47 zo=G-4GzG|SaP|>i*Vijbw+TL%PrR<=Vh7o`KvP!xZv@f<=(gxNJN#xW4zJow47;~Z z|7q6&w721;%T(7boV>0s&%L&OqsZyknec|3=2I|fTL}}MK)Rz6yI_Okldx-Z8>D$t zHA;=BPu#@Lleq_)pj%FVCrjYn&E89>g#2(KeFw< zgYPu?!wdH}a3w-e{UNh6WCj*0!?lA#Kt zOa)3>EZgROtuCL^{u+r}6!KBhi|&*uJB#;1*99D?H;Mb7q2OfFAle-t;N)MNo(ae0 zG-P5&+id%09NljhUOwq7vN6)$K_&hcIhFJE@gjez4ZG6sB$w{t_cvRhq?eECRNCb% zEo4atOj!So>ymcYH3})zsjj_L-mr57Gx{I%w)iJI2CphPngx9 z!QMOgqDLg#cg>bNAL_>5-pu3~>vZ6R*%Am|cm{@iaKp1Pqp-=0(`*NJ#Yd6HVQ<$y zu*rQtUS4#Z)BQqvvtqcgzl~hBb{9TAw?i@+U;v>@Zn18=uVDR12A`T~!o~`3v>a)Q zGt-~*A1^wn@jo7N?cXP{#Zmgq)-^k5*@Dkt5cWDVmdw;J=ljO3yMkh`St(1K!wS_kZPl^!K;yPpiII`S}41JFh3d zUQnOz(Sa9TTn@jFtp%N_PaxSf2tLC?IO%yj-^S`cc;s0lYRWXuG{VfBrqIc@3^olc z!rag1IQ-iQd{)vRFIheai#(U}`DZj~e{&NjYJS0mwh`cPqY2pk>Z3-j|CImy+hXXg zznJP<4_j4l#c3uxczCro(7nn{dYH;ob6mQ0f%I}pElaDkg6P*jarn<>FuJxF4V|9@ z)eUCn8^}}#9PjG~Iqw_R)tG87mp^k=7rgnvzC76hPj_p`Gt+0_f;S7H^w2wIY377i z%GN>G>4TB=kApMc{c_t{H$>}ErOf|FOV~LcIGs6fx*(GIe?83&_SAyymH{kX|E;p@ zpPppj-AX>YClfsFuOZb-Ze`INVr_>(+;%TMa?*djUQSsFURQg;mO-oF@|^GNQ?vD~ zc4Q=Ly>=?J9Y?<#;2Mh9;SP?OHku81--Jbm#PEp6pCMTq2-64UW9G6i?7zSxpiard zLjfV|y52Ij+VL`U7+(lGC+%TBEwqv9Utp|%jP|CM$ng`N9J>`KBwT_c{f(vlF8W|O zOyWiKv(QyqLD=!jRZ(M>cxeF!_1?o*X@9-}vkO>Bg$WoPEM}P*x7dOChVt6$xA~@izA)!?F6%ta zfC;Ub5H}Y^ADg)4!07Mua7X@V-fg!BpJrmnie0YrKjzQD|7&k4_+Wo_DtHyn3f;ml z#Jz-o>A{$Cb192a{gmxvQb-q;VCYdN#?NnJsq8ObU;YLiN=5?BA~r73hwCPKa>jsR zILK@{J!=|9*BGE~@oX5a(LjwbbD-6YpXd#yGj%E!UEhY+GDD=lW7=}=o$NK1M{ zw*)OZPYn>?Z}mY#T@fG0kLaDC9t(t;dL1cDdp*=_2s2IkFa+v7sel4 z&wU2gQ#U@6P+!`No(`RnbP6AB--`TfXXxHz0`zq>%cd!g|u6P`s4#Hff(u?W(}uMrrVRt_^mw9gVFX2f~%6E#cXv6!40aA&)`I*p}gW#eb}XEiD1 zNPWXwhqRHj0(! zfX7Gdg+q%|;6DAl-p@P*A}qE-(!PAIIUyJAyB|PL$4ykf3Lp;=^Gh1M0?#|gLdv~k zSbux0=sTfNAUtY&s|(W2^vBA$?$6kO+*FAFyBi02I;ksS|1asf<8o@>xTvJa9ucx- z7Lm?U1;$ey9jeVsDONT^6=_Rik>_r3f5@qS(&^*rZX_jP^O zbIyHq*?o9$WXaVp?UI*UM{&t1DaO33Tgi7=bt>Umi81SD{p2BB+_rqP#~Y$)pq-t*xnUB6 zbF}D86TIG^9pn7@?%Vc!DeJCQbVqTzUbd|)eDS3O$J63n+XZS#e$y+;{yxt=EZ+9a ztLFt9XGSe$(XrPO)Mr(EdW7zI`>Xe5aG^+zEkVsYuO(oh7PL;%uGFPd4t-G`$X1=VF8%?h!FGbzUnc!FkA24`9TfFVL9B|#4 zi(P9bfyunPh#PR`J70aYif&ceK^MXw?HM`K!RIASu z={~I<&2~912gio-kei8W9vB?u@uN(4Zq)b|YBN?AOTWWMO3!_N!1Zt(t{?S{EnlntVQ{#)++{h7g;6%V-ZdzcxfF@z}vtC1IQk0dg z`Jt_~j=H07Ynk9&HUevDTed7_;W>=^T5jSkBhPZ7e)^1&Am@K$r~%3sB(Hg~z%?jq)Y?y&;=D|4?kz*^_izqTGu`|T%c6T6S(z~edr zulU#EIM!^v(SM=ZkeieXqR4yFEeT$dt`}x7=1}QG zf)0}TjIKH~u29_5*V2LK4EZOnKRzYj-*(E=$mkV@9+UTKTl2Sr?Km(#f)(x++)!Wa z@f;*a+;^eBJ=;oPfCjv#p{-+h$ejwh>aqIgg}$w^mu@|B1;^ek%)mQ=V+4NK5Oju~ z8oOR7Z5Y~TC}Um4>rGQ>f9HY(zT$PcTS@VhwetmPJSmL3509W{V~U$i_1)BL!gLA! zBEjM8A7>>tRBy%;S687IX~fr#I_S&V+R0k|TWa7-IEL^VR$N*xk1fmx}JlsE^surv|GT1V2EtIMeLL>j{0p zhn_U0Rjr??^W++DaGhj_X^dl}5B4?84$JO|T75>b;-HA+TeP88C}$u3%n73HXd`&BY4jyHuIg-+Yd!+bAEd)JTjbwOo0c>7>7 z2Mto(#9l+c2z5N|Dj(%?2g(7jE#<#8OBubbolVW+g*SqDR!Vow%6jZs=|XW;ZOQ*g zZI6FTa%Fz+4cxxR0Qwf^2W`Gu{(i{vf>!`iZKmqEU#MRxY7$eSAsEamhfUnO)YP4mCS;2kj_+gfvY=xDZB6eMge z93f~U#S;wA;4!w_K03FhHG_*u)phmk@tk{O4INqPWNz_sB8#^fst4h_B<;JGtSz4S zn4s7B+q^TfOYmy`x2h&)x)JBv2FcZa7WA|IS6qWqN%{_M4-e|W8_!lzJjW?bgb5B8 zN#pZY@FVgm;GbdYakk%uu zrJ4*GcbvOnbgo{Cx2|^%)lG{`=eBex@zsJt}~AJwHMWT zcGuL;qK~)?{V>KZ%Ea#-WMG&mw^wqN+-hn@uzt4s=6WL}`dDcTXxv0vy`4n4-; zXbnDxIypmsJP+YLVxdGY(10x+Fgwolv3Z(!aq+(R{=!nUHLNAh#e@%(ZD{YVk$HYt>1*Jthw_A~7SJHF?Gfy8+B%Bj9DB%? zhu!tZfiI0m1D~=HY-t9s!agfiN+lea-@pWakvbo&mz>z^lu(!o+~tom(j|Ha*8xT6 z)%$TuCprA!Y6(xRbh>zd{h0(e=oN5JbF&@SIH>$M8dJ6t*Q`~ZFl+IcFY&?dg5nkO zEZ)uR%Ck5%<~Z(uJ588(2@k9(J?c9wM)_$8t~J0T@RsxVg3TTpXqBNgwQkR-83W&i z^{_nxy4FPIK<{sFQ(gmWkG(~&?3VD-@?vva#j_M%cQ3#3+(2EEu8H=&PYC5J@8RA? z)Qd~o^~%Fi^eR;?|CXP+AVa$LQ(oOUiG7~!02aCPu@{#Fv<$&3B6!MXO-usdTs7ME}2meuAWq!m9MWCbXzK|JN>jl$Yr>=_gs#=0Hv~MFiMb`O(T6zX{V`r@zZV%yfB>c`P9tb0Ww@~DlNrDtgZBY_uA;4YF3B7@m6|M3zyjwCY0%P0AxX zl~MT{Z9J6>|K_T+GbLM=(w1a*pu3+JGx~#&OEGwe50@UIdJVj>5*}~_dV8|+H1bR7 zux6+;sTrv8=8mScs#<&WuUu+h2YA1Y?cJ`jn1j7r-wfg{KL4MqqjBx|aY|}D-kXZs zM)0rrP_f8jp~{A2T;&6X@-m5`n~XD?*GSZq*S7zqfxk%MKW3c|mlMdKf|OoDzBEZ? zDMD%U&I5O|n&a>H->}E6IHivnnTJ$sj_i`arKI#Tu!_(>;)DH88Pv0rNH2MqkPAW& zbztNb;OU2=bn&$WZzmk?JW|}FXO((kz<)5-F;547J9v)0HZ>N&ZVB(`p}b$~?p3(q z$P4nR`MGNS_GQJ9}pUP*adaF`g|xf^mQdmZzwvN7eWjMiP+Fnp}3a=%{- z`!V{EN@sT!;0ncq48NA3@G$A(7Q$?bh}4qs=&w`;E09O=3IFx9@Y*r3Bg?w6M# zhP-b~PhAd^(uI5XoYE>eoM7d@l*TH$BiKL=1fGlLsoq7o_PGKay7rVnKB2!U*jV}I zJpOgZ-emBld07NJCwXlS;^&c}G_g-bUEw5rrpf{s-hxjz=qQ!$sOq#^#1wdH+-kVHnTAs3=?hvy>qTE);u@LY@8Sd+?~K2}C%8JwW-V#K2&X5mafmHCn7 z=_HkvC7f<>kq@-JER@%VM?=OqQE3dd#wL0)PajAAEvqYzKz}Q*pk@ucTr%&NH&hu! zk-s#CA7gO2RGB6+ae0yIbJ({Uis#AZQXul47F53I82v?oO7#4y1M)jdt$p8;M(tA< z2{{|p3mV9nIjOQkud!OXYVJmBILquS3Ii zRQHB`!Z~|f8)s+9eGgX@pZ|*#A@xd`$%_g}k=jr$dz3&uD%h9{#_r(;qtp4OXD4I- zjcW4n@=`QlUP=B_d=Q<^ZLe+G{*#;|4zr8TJYME7*?8^$U0yDI5&O3mq5cC$@$iIq z35WRtMQK4|jZ-d3$G`kyQe}P`?M(YV?3TMWeBk#p+f(tZ3Y6O{No@UlL{6LK%}xi3 zn5|Fj=H4?x`Jmr;E^y|Hyy$b29(`_RSls&=zh}sEI@QWTIv3s~+IQMOKiUa>O^ZlT zchNo`mr|CGmcm|{Ei#Ra>3-59uqSsc<f|IEue>JiVt-g-i_y@a2a^P>i{ zZXL)|2ObbVThXHbz^+0;_%*rg?@g`TfNHo%A2|fAph=*-C<&jXqOT;(5Me6q| z_VL4OEu{bY>!Q#gJH5$SSG~}nHT-jl1Ld4_;qbc+_(abfF1ksVU00oBSMwen+U7u4 zex9XPZ@lHSBSDk_%$V?JCO6t~lUBbdVtVA$W!+&)0)>#%GcNp`s0G^Th-bed@_f8kNH)F3OuUL zRc9~dW#{~9=b^88$2&Y>3ZHBHPoU;_{zC1^M^VvzWK$jJc=M9sA4HMbfuZIh$=j7K$gKem1 z{%!R4YafYzAoMG1Pu;XWx*g*^#n8pUbal5s&ulrLYFsVGST7xaW?(9hX>>#W*s?Wm z4VQHPu3egtUko_X-b@*VYsPU5b~P-xxo0jgsy=Njksw-xcBi-dGK76id?z#Vg63J( z6ieM^kp1hg!g_!CJnVZhDN06kZ;ADBX}m*?NBj z#r1IG_3a(#K41`{xxEj?_+?00AQR&J0$f6%tRNfNU|wk=kO=I|q&noL{Q zcGTu=ZA`#W-t2Ia6z^5<|5w&3_)gNZ9HY~%KB~{nM)%gL_c5?$JafJapGq&MJ5F6r z&KaYq;o^VvY*lr-&}olHiy;+w;<^Anl-<=hT(X`XHqKFCKFD#Z6#=U?kA7RYeWy}{ zH37~VG&#tfMkYG&+=OPL{H}FkWa1|J{;nT8Jvq%8d)(BlDehq4fef?_ra2?}=rL9< zVwg{1zFDvg)z6OCRu%}s^+84S$G47Yqh0sQ*ukd2?;Gc{%P2gOZT+_9_2jOrJ4rsh zD8CeHY}(8UV%>L^_&uf+-fJyo1~vqKKG9zNe!ykM734#o=JUWeJGpPrWYPJDC8H1c zc#}gM-VOKacJ+wIOwhf|CS0rF6z-l}U1B}O*S81w(vGJ5ugr20{nAfyxBjzkQ!Zg_ zMZBbulOwXrh?1>YqziS^qPBV^b&<5x6 zJ^>;nGgqEC+m2uR&*5sRKC<2DO`J1-J~V?1UA%cp`YTVQW-l1XzJ<0*=p8Dw zK8buL`P0zF#i`tQc%W;w&06Ui1A{rzjm5-A@0%q&sZ5<*68LbZVLWEF2cMdIoTF!# z(}&ppBeTD^!dY26_oXlbp9#fxO!X=J?m-4_@ciQEJoX$=}D*zpZ3{u}(Z zI|a5(p{Bd{3-AsD>*>e!UcBa|yQu!Ev{0JrQRmGB4wut6uHiv#R?~_gKOP#NBHWV) z(Wi}0T;YtTmY$rz*#o*`R)c{1FA4afV{XmhTDTV}HbDT#sr#?~#B@NYo}Kohjo#$v zXvO8!amhz$n7XFYJF~8Zlv{}zl!uCIlo*wB~~gdc(wBnzrpt(O8<|`1I+@|iFNuj>w?v=(*;MC4Ngeg&vL&yG?UmPvq{A;|gHO*j zg@s%Hy5^nlm#|c6J!Q?{s|-Ao;e2WDTpiHt0AS9QMOd!4c> zP7}~PWaPW5L7x~n9)9H>%AFI_sb%Xj>=^5zv&9c_(Wk%s7XH~#^Y!A_35gA3pqpz! zheok?rW1|)(v^L>-r@6g=JC7d4Rz=)IeEZRBha=uIjndftA!kuIrg|mf5cYKI%K0m zUsCl$!*ys5U7ZK`T)HRU5!0(j%is}}H1w@LzTAJ>tDOtLef6p2qDRs(=Z#Q$yy4=M~>8(RI z(Uz&@750$d-KYG%zO#sojXcQWkAJ)o8u z7+xECKN6bU)`Y&HD(h{am08U{F#0ghjf-`Bm94b{uQOEG2tGXuOz z;3N)ffIac@PoyDr4)XOGbqKsiE4A}7scNQ3)cdje>R=D-s+izrL-Hz8%2X&!36YLtJ>%_;Z~9k2B{x zeMzX=TmHS4#~VMFY?3PEP})rKkU$N%*4%6=vrW?2vOVR8fvsp>)^idRE}Ko? zVJIyzIq0PP{iQCU))aZKzW@jG{o&u_x&_fnlMwujaLYMCr;mpS=s4)y7@Q;jG>Vc%}G}zb}|V zh3AZ7->~Dn)a?^lbbm}ri>2c_I%sEsb(LcuI5Or%{)^cRO@MnFZ5MI$!8C1n-TvG@ zem;0`12;ailBZv8ZWgkv&HHae6F9)^m)3gEy|Yc=9y?6EfvpfKd^(w?i|a|q~y1HiSQq^ zjTa=X6>G0F(#Nb$kV-@KOzFl`(?SKjpuCm#P}(oBH{naE)+TEcx+YHt2n#%)4Sc2g zq-0caeMO%%fwfRRRZx|Z`jxZml=o%ZmsO364XaD|STYYaXP+2PIk@dHdi8r4D<4w$ zSh%KiPWz(6SYdL)lPJzid2C=tNv-$47+f38{}^7{nA!3-uGwG8;1v-$;XNt;{-E7- z?ulokz|+ZQPyEHy;D=Jx26III*kAN(4O~AG)I~hMUr^t8XBofst!PB7_##G)eIVVN zCM(S=FMMh%6>h*20ILS^4$Bn;|6!bs2s8?}bkU*x@b7s3Pr-M5xpYT`>+omwO~vP3 zdzMf>OH_J{doI4z<%1>io0fR?N!*Wg+_&=FP_yn(>bv9_4~?VdM_Kt_JBD{-%&4B# zzXk!<*xT6>d>pKB9ryngQ9Uk-gokkR^rhJtb7e-S%>}*=yln5fFZ#dBrKjl zUpp}RQrwFz?^7GN|a{H4T%7bV)59*xQ!-Q|8T`lc-&WJ4V{8W0CQV*E8 zUilf~dTaR%Z?Q~p1t0+EKJudrs9?#Pdm;;*d=ZlQ%5yyS>HONu)M0k5~)SM2iz_l<7 z0x943MXY-FY=nJYc5z^o&#}VLRUUp*HQ_6bY<j)?`=Io)n3(x?qsYHDrb3?)IZPLLdUE8$OD-YRsMI5p^3ni+u%{pu<9ddXycJ- zX|^x=AOU(jL1`o9ec57e9v-MQhE9{13j=%h%<*0p*nE*0X$Hm678=gakyg^sm3A&AtZ+O_pwL;c%S zk>q=29548KN`n98tl|53bkbQmo^y#pWgnsZ$*r{Ej2uNmQ|q%A+$Q802|i_FsYfr? zYz`y~x7a0ld6DuWB|db*x;12#v8g_EY?2{?o2swL&ign)vykF8=v33S^GvFhZb<0S zJRf|fbzMdd!1pqbYf5kSOu2##Hjr?Pe9zgz#CZsn6RKH+Z!?RISPi{diI3czP0)bI zgnlUumz4u@v;|*Vu;OB58?DmJVKLcFT#0ng9eW9LQ7Tll&8(% z$LPzIzY_RBS9-?6+kF=*D{7f>fa}TC0-2aL%eDiD$}G;YFD15Js0GivQM^cA!iPpb z&FdRzX#?vaE&RiTHlvFi-npQ8vV0wtQDgqC^(QW2qvMQqTlBYui%@m6O3qbq2AyCdRx-%MJ(Cx^d% zieZn$cRaO4Bffn2xe?KlY3P58#NN$!@;+yNzig`11QZSF zoh|>2Ck$`NgRHBPN9SAA`D?sztJ3J?&&{>D^WD*6aH)n=XXjGxU*R<+F4)cXhx&8N zc2A^t?iE?f=wg1@zJ=3L4UVt7T}~UjUXIBurmwwyLu+`vpQwG|7EP-;lpLxq(>&W$ zq%&?F`h@nA3Fjz1>!gZXiG}!Z^nQ8#@-^9Nax%ws^5Qo0dy3w6VKi_;T}r|JG>(@F z>O1H>JDgj<^F~$_u}#*{$5L^0vOr7vIO&^Ml95QMH~b`OMmE?DF)YIy9S`&S0@`2R}LtABmSw|E&dtzQgvTRx6jq_og$UXS94xL$O# zT1k&n$3k&$aBZ6IHj%f@eXm{ikpqL*~`lxBQ(^MKWwk451QLdxA3niXWpB_+OzKT z%|~+l%QvcyvU#y@gx=I~ZajEpidvtf_aQ+teBo#r=UdENdh)e#w0b)2c)XO_hfkmr zUykwH@0+OP?#+DIrHsC8;&CyrW2&%<>&ip_`^Qf^w56`EKC)Ytqdejho)e0-!g=dc z8tvpiTc_}>%GD^SS&VRgpU@1m#*>o*seZSW^r`A;e)cDjLd%w>cRdHwvR;p=NZ z<=P~6DwB^CDMM=6RjBs z;k#M%meBG=i+4Ha_Z_lMS8>|Z&G{nSruzU6_O`uDFW*yID| zAWLFxscdc;>hAP_SNPs z_@eOVZY8dfyk4EJ4tUMzO?I{|!VkXcRODh^^xqa^bN7z2ZP^Q)a*xQN_jCCup%f+E zc+INT5B9ATe)lZR8*g?}SdFS2SSO4ZwX!GG7w^HvmvG%Q3RO!nyyemGXAX32r~ehR3#zmD#1OaZba?;t%on zzzTZ((-rxMXEj-NXnj8Fvy}eCpAjz|oV62UQ$^s+u4Z9b37A>Jw7TL%@$vp#HnTO( zEr#zLJxiZL`>DF{r0U_k^yCCWU1-WqDgOH#LYOUT(y@f<4>9+>wcggX0zJ(-NaiC~ z)1j&*VZG^S*fVhvdm}DgVZ+f?KZ_}iYRF%>9~J+ua8@tT#G04g@2poC{+SQS2C7)B3+-3T}#2mc(zPu?x&qS^6e^U4r= zPBfr}0m&MUmp?REDT9YunvI4G62KeG1om{_w?vi?EzgVc4dT5yfnu)E1#m#Em#p2V zwdzR?{UqDEM#{}+hSH)LA82pSos=F~iPlH?8g3I`2*1UDx#*~>z`$LyRO=849wyA8 z#PM**!&qZUhNlR&{HricQ~W!6Ne}bExa*u=dzC1ivsz^4pQ7eWw%!;+u2$hn11PNG zcN1LL&N@pp<=PA$#xdT(UceJ&#jxSj@akT&y0VQFM}>D?LaKhKqny9VUpr^D#lYH- zSN2pfGUDP0 zn3;(@Gh@#_dG=*~ivCbcw)XlUi%mZywpkBU=R&{=G5+n|gl41ni=>^aSYgJw^>{{% z>orkk??`AeJXa*j(mcKC41KpaN(WZ1&%?~pn{Oz*GN6w<#`SiiE$I_!dbcFbUbUHk z!Tc(&vU${}K;Ak(KEGYV@o_?L+@Iyw(%hFU2v|>FJqOW-`CIYxCwX15WL{ry2(^uI zmHM8V0@_3*Y<1^eeJ``^gdt+z+~x5lcRQ*%B&D$i|GWc@D0pn$>)a#ct1+SdTUq_v zOp(6$5&`2y+03)_&Ci}}o$y@qYnSPF#WZ>2TBfMf1!07dq4b9ENiJY6Y85D5?zz*# zoAZd81`%eMuXG-Qy)+L&4-TM;N8Gdt!%qVvav3$@Cr(f0Fqe+txcdTqX#N=Zg{L0t zpx5wl=959#3j-MV-;)*;96Ux|_|aDvwU+|hY_V<|1u&P=o)j^az9?YVNn4+AUjFUv zp=L|zPC3GFH7`0IBgQ@0;t{k@W8kuc)|39Oy%Q!U;abK$HF-_*2R#059UVQ(6Ux|e zK>m}28Q@`MW=Zs^IQ8Va%$m1ON8eD~&KbHohG9=j2!(waW9G=GBf{m_x?a>>J4V;) zbT^f26#*(OH!E)Qj(=mC>QQbYP;w1Xg{u`sO-@{!zHbVt@}Fh@UCZ zV@6@QmBB0YcT!>XZ@R)9rLA3hbfb}h8zr=~QDo&Dajc7r0C&;L?3p|%>>yi5%pkxFlNt_*h2~DkMq)@ zJ3Y|2U53=j-r^R;DiDPlxK5R}y`h#(d&xvodK2 zt>cQ-JLhSYa&2#mh`3A&9<~g;a!F$5DD3DOE?ME2@z17!UjFT1vGhkJ0i1?rt-{TV z%@&r8ah>pB?3LH*2dkew99>USTm_7yAyr9YCV4}lRmwMj1LB}hs^YWp9LgEfRU@} zlShG4&~ZT3oUQ(C|3@?h0MSo27uprt)He+fSwQBG`)TCI9irZ4VH@H&*x z+d_{Vx0xDvZ;)-~XP`F(ukO4{%{KR${+yJ)o!x#ZgC7_e0Q@hfyL|JPO2_Yf+9_eU zXJ7QC4fSbhY4&>DR2t{PM4z|_hL%^JPn>#Qg=|gL2h}K#QWTPk0g zcm$;cNN5FyhUK@*3z+^htfAjlY0e9x<%))db@U4<{|;Va;HiOQgx2uVmB)ftR#?Hn z7*=^g;)2>nH?I}SFL9rL>B{Sw3QHfC=+9@%`_SiXfAsijxwq(a#<5az?HS@(gXsb~ z7Q7LtAuo{g3vX9zE<1m1W*&MwB2QPU{%Tj zZe1;qps6vFgBaRd^@{SC5`3wZnVV0XAM(J!c?osD6f_H?CglHYGsB}wU?QpaK&~OS z48EbV5rtXin&!jA&5fALRE;O~`6W+w`zDnBj(c9yjOlb;dzaHL5t?2^9~z9T!4jSz zKuo<{!HClEoCttT0Nli)L=Mqg;P=`~US$S1wF_j{&)L>{c>NuzsrxqNg zPkQvkfHxIwaOrFAnf-iyVtx&OHjz8Xgk__|;7K2m6|Lm56@M!2CohjalJ|G?04>D# zBMSSl=6M*Ya)IMTS4!)z`AlSU3WH5(E$wMm0|Gy3ev21M=!WAx@|>N4A#A4Q#($Xdj;HaiT3zvvqZtWy?xGl=2$4D_C?Wc!`l`R54e zHHlnELN6wuPv}eZ6Gm3W_H;&OuDhxDC=W+n?w#cFHm>k}i)F@`@9GAc*2Qj9AFHgh$uaB2crdHVJ`;g5E=~r7YvNN;)ZZ94Aq;wh>4-Rpp z9epG6@~|V9)^f!oaooID9fnR~V59OY39e^H5qL*@4~>Y#@gms-Xp*%%`KK^lNVu z9h!#G#}Xbv6g+cV08gdD+D1d}5ON>o$5pmx?wsyMAAb&G#Y@Vkwki$n+r*C0!{+x2 z%NhC|nZpqHw>I?Nx)%R%Ue5<7H5W=>tXy@K{OreyT}A3r$mC#BK2P-_I6#MvQLpjP zPL8_bVCYZ+ck{rU(_FkrO+s#$U|z_{%i;poIqB#z0Y212CKpHcs$Ofb#{{Fcb_keh52uWL2U%k@f~*aOUOB#g>;Smkg-vzJrKJw~3lfs`(? zTsukj7!kti7~uysg{#0>fwnXNjCml>1qY!M_^(rP$uU;NO{{jCEz`2?N-L z>qN46?AGzr?fhoSmvvX5)+W3X{BwSDv~v-*_`|fk;C>O4ZF1KrPQdXncv?rcN;N9q z@YuAgEnOWjf%-K`)V6gfV#2SB7V~Ub&;N>%w{gzQ@+LA%IdMuiiEIwIxSF~~ky-3% zTdEenTvz$!?}}Z(7aLd(59E0~j2hN{OUSPTbPleCT28N~=NhVKkyV@U!^(F6Kb`VC zKYWc09P|_U Date: Wed, 18 Mar 2026 00:59:28 +0300 Subject: [PATCH 03/49] playground endpoints improved --- src/backend/app/api/v1/play_ground_routes.py | 54 ++++------------ .../code_elements/play_ground_repo.py | 13 ---- .../app/core/services/play_ground_service.py | 32 +++++++--- src/frontend/src/lib/apiRoutes.ts | 1 + src/frontend/src/lib/queryKeys.ts | 7 ++ src/frontend/src/services/playground/api.ts | 64 +++++++++++++++++++ src/frontend/src/services/playground/index.ts | 16 +++++ .../src/services/playground/mutations.ts | 52 +++++++++++++++ .../src/services/playground/queries.ts | 24 +++++++ src/frontend/src/types/playground.ts | 49 ++++++++++++++ 10 files changed, 246 insertions(+), 66 deletions(-) create mode 100644 src/frontend/src/services/playground/api.ts create mode 100644 src/frontend/src/services/playground/index.ts create mode 100644 src/frontend/src/services/playground/mutations.ts create mode 100644 src/frontend/src/services/playground/queries.ts create mode 100644 src/frontend/src/types/playground.ts diff --git a/src/backend/app/api/v1/play_ground_routes.py b/src/backend/app/api/v1/play_ground_routes.py index 0b2b19f6..3bd3deea 100644 --- a/src/backend/app/api/v1/play_ground_routes.py +++ b/src/backend/app/api/v1/play_ground_routes.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from app.api.dependencies import get_play_ground_service @@ -202,52 +202,20 @@ async def delete_playground( @router.get( - "/owners/function/{owner_function_id}", + "/owners", response_model=list[PlayGroundResponse], ) -async def get_playgrounds_by_owner_function_id( - owner_function_id: str, +async def get_playgrounds_by_owner_node_id( + node_id: str = Query(..., min_length=1), play_ground_service: PlayGroundService = Depends(get_play_ground_service), ) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_function_id( - owner_function_id - ) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/class/{owner_class_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_class_id( - owner_class_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_class_id(owner_class_id) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/file/{owner_file_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_file_id( - owner_file_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_file_id(owner_file_id) - return [_to_response(item) for item in items] - - -@router.get( - "/owners/folder/{owner_folder_id}", - response_model=list[PlayGroundResponse], -) -async def get_playgrounds_by_owner_folder_id( - owner_folder_id: str, - play_ground_service: PlayGroundService = Depends(get_play_ground_service), -) -> list[PlayGroundResponse]: - items = await play_ground_service.get_by_owner_folder_id(owner_folder_id) + try: + items = await play_ground_service.get_by_owner_node_id(node_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc return [_to_response(item) for item in items] diff --git a/src/backend/app/core/repository/code_elements/play_ground_repo.py b/src/backend/app/core/repository/code_elements/play_ground_repo.py index bcc1229a..bac4d362 100644 --- a/src/backend/app/core/repository/code_elements/play_ground_repo.py +++ b/src/backend/app/core/repository/code_elements/play_ground_repo.py @@ -68,16 +68,3 @@ async def get_by_owner_field( return [row["playground_doc"] for row in result.get("bindings", [])] - async def get_by_owner_function_id( - self, owner_function_id: str - ) -> list[dict]: - return await self.get_by_owner_field("owner_function", owner_function_id) - - async def get_by_owner_class_id(self, owner_class_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_class", owner_class_id) - - async def get_by_owner_file_id(self, owner_file_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_file", owner_file_id) - - async def get_by_owner_folder_id(self, owner_folder_id: str) -> list[dict]: - return await self.get_by_owner_field("owner_folder", owner_folder_id) diff --git a/src/backend/app/core/services/play_ground_service.py b/src/backend/app/core/services/play_ground_service.py index d0edd095..9d5bedd7 100644 --- a/src/backend/app/core/services/play_ground_service.py +++ b/src/backend/app/core/services/play_ground_service.py @@ -9,6 +9,13 @@ class PlayGroundService: + OWNER_FIELD_BY_PREFIX = { + "FunctionSchema": "owner_function", + "ClassSchema": "owner_class", + "FileSchema": "owner_file", + "FolderSchema": "owner_folder", + } + def __init__(self, uow: ProjectUoW): self.uow = uow self.repos = self.uow.get_project_repos() @@ -120,17 +127,22 @@ async def update_playground( async def delete_playground(self, playground_id: str) -> bool: return await self.repos.play_ground_repo.delete(playground_id) - async def get_by_owner_function_id(self, owner_function_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_function_id(owner_function_id) - - async def get_by_owner_class_id(self, owner_class_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_class_id(owner_class_id) - - async def get_by_owner_file_id(self, owner_file_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_file_id(owner_file_id) + @classmethod + def _owner_field_from_node_id(cls, node_id: str) -> str: + prefix = node_id.split("/", 1)[0] + owner_field = cls.OWNER_FIELD_BY_PREFIX.get(prefix) + if owner_field is None: + supported = ", ".join(sorted(cls.OWNER_FIELD_BY_PREFIX.keys())) + raise ValueError( + f"Unsupported node id prefix '{prefix}'. Supported prefixes: {supported}" + ) + return owner_field - async def get_by_owner_folder_id(self, owner_folder_id: str) -> list[dict]: - return await self.repos.play_ground_repo.get_by_owner_folder_id(owner_folder_id) + async def get_by_owner_node_id(self, node_id: str) -> list[dict]: + owner_field = self._owner_field_from_node_id(node_id) + return await self.repos.play_ground_repo.get_by_owner_field( + owner_field, node_id + ) async def run_code(self, playground_id: str) -> CodeResponse: playground = await self.repos.play_ground_repo.get_by_id(playground_id) diff --git a/src/frontend/src/lib/apiRoutes.ts b/src/frontend/src/lib/apiRoutes.ts index bc366ef8..8f30e5ff 100644 --- a/src/frontend/src/lib/apiRoutes.ts +++ b/src/frontend/src/lib/apiRoutes.ts @@ -9,6 +9,7 @@ const API_ROUTES = { CALLS: '/calls/', VERSIONING: '/versioning', TESTS: '/tests', + PLAYGROUNDS: '/playgrounds', }; export default API_ROUTES; \ No newline at end of file diff --git a/src/frontend/src/lib/queryKeys.ts b/src/frontend/src/lib/queryKeys.ts index e9dfd516..2f0cad4b 100644 --- a/src/frontend/src/lib/queryKeys.ts +++ b/src/frontend/src/lib/queryKeys.ts @@ -25,6 +25,13 @@ const queryKeys = { cases: (projectId: string, nodeId: string) => [...queryKeys.tests.all, 'cases', projectId, nodeId] as const, }, + playgrounds: { + all: ['playgrounds'] as const, + detail: (projectId: string, playgroundId: string) => + [...queryKeys.playgrounds.all, 'detail', projectId, playgroundId] as const, + byOwner: (projectId: string, nodeId: string) => + [...queryKeys.playgrounds.all, 'owners', projectId, nodeId] as const, + }, nodes: { all: ['nodes'] as const, detail: (nodeId: string) => [...queryKeys.nodes.all, nodeId] as const, diff --git a/src/frontend/src/services/playground/api.ts b/src/frontend/src/services/playground/api.ts new file mode 100644 index 00000000..f482a775 --- /dev/null +++ b/src/frontend/src/services/playground/api.ts @@ -0,0 +1,64 @@ +import { api } from "@/lib/api"; +import API_ROUTES from "@/lib/apiRoutes"; +import type { + CreatePlaygroundPayload, + Playground, + RunPlaygroundCodePayload, + RunPlaygroundCodeResponse, + UpdatePlaygroundPayload, +} from "@/types/playground"; + +function withProjectId(path: string, projectId: string): string { + const params = new URLSearchParams({ project_id: projectId }); + return `${path}?${params.toString()}`; +} + +function withProjectAndNodeId( + path: string, + projectId: string, + nodeId: string +): string { + const params = new URLSearchParams({ project_id: projectId, node_id: nodeId }); + return `${path}?${params.toString()}`; +} + +export const playgroundApi = { + create: ( + payload: CreatePlaygroundPayload, + projectId: string + ): Promise => + api(withProjectId(`${API_ROUTES.PLAYGROUNDS}/`, projectId), { + method: "POST", + body: payload, + }), + + getById: (playgroundId: string, projectId: string): Promise => + api(withProjectId(`${API_ROUTES.PLAYGROUNDS}/${playgroundId}`, projectId)), + + update: ( + playgroundId: string, + payload: UpdatePlaygroundPayload, + projectId: string + ): Promise => + api(withProjectId(`${API_ROUTES.PLAYGROUNDS}/${playgroundId}`, projectId), { + method: "PUT", + body: payload, + }), + + delete: (playgroundId: string, projectId: string): Promise => + api(withProjectId(`${API_ROUTES.PLAYGROUNDS}/${playgroundId}`, projectId), { + method: "DELETE", + }), + + getByOwnerNodeId: (nodeId: string, projectId: string): Promise => + api(withProjectAndNodeId(`${API_ROUTES.PLAYGROUNDS}/owners`, projectId, nodeId)), + + runCode: ( + payload: RunPlaygroundCodePayload, + projectId: string + ): Promise => + api(withProjectId(`${API_ROUTES.PLAYGROUNDS}/run-code`, projectId), { + method: "POST", + body: payload, + }), +}; diff --git a/src/frontend/src/services/playground/index.ts b/src/frontend/src/services/playground/index.ts new file mode 100644 index 00000000..663f8472 --- /dev/null +++ b/src/frontend/src/services/playground/index.ts @@ -0,0 +1,16 @@ +export { playgroundApi } from "./api"; +export { usePlayground, usePlaygroundsByOwner } from "./queries"; +export { + useCreatePlayground, + useUpdatePlayground, + useDeletePlayground, + useRunPlaygroundCode, +} from "./mutations"; +export type { + Playground, + PlaygroundOwnerFields, + CreatePlaygroundPayload, + UpdatePlaygroundPayload, + RunPlaygroundCodePayload, + RunPlaygroundCodeResponse, +} from "@/types/playground"; diff --git a/src/frontend/src/services/playground/mutations.ts b/src/frontend/src/services/playground/mutations.ts new file mode 100644 index 00000000..05336d30 --- /dev/null +++ b/src/frontend/src/services/playground/mutations.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import queryKeys from "@/lib/queryKeys"; +import { playgroundApi } from "./api"; +import type { + CreatePlaygroundPayload, + RunPlaygroundCodePayload, + UpdatePlaygroundPayload, +} from "@/types/playground"; + +function invalidatePlaygroundQueries( + queryClient: ReturnType +) { + queryClient.invalidateQueries({ queryKey: queryKeys.playgrounds.all }); +} + +export const useCreatePlayground = (projectId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: CreatePlaygroundPayload) => + playgroundApi.create(payload, projectId), + onSuccess: () => { + invalidatePlaygroundQueries(queryClient); + }, + }); +}; + +export const useUpdatePlayground = (projectId: string, playgroundId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: UpdatePlaygroundPayload) => + playgroundApi.update(playgroundId, payload, projectId), + onSuccess: () => { + invalidatePlaygroundQueries(queryClient); + }, + }); +}; + +export const useDeletePlayground = (projectId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (playgroundId: string) => playgroundApi.delete(playgroundId, projectId), + onSuccess: () => { + invalidatePlaygroundQueries(queryClient); + }, + }); +}; + +export const useRunPlaygroundCode = (projectId: string) => + useMutation({ + mutationFn: (payload: RunPlaygroundCodePayload) => + playgroundApi.runCode(payload, projectId), + }); diff --git a/src/frontend/src/services/playground/queries.ts b/src/frontend/src/services/playground/queries.ts new file mode 100644 index 00000000..a04bd5d1 --- /dev/null +++ b/src/frontend/src/services/playground/queries.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import queryKeys from "@/lib/queryKeys"; +import { playgroundApi } from "./api"; +import type { Playground } from "@/types/playground"; + +export const usePlayground = (playgroundId: string | null, projectId: string) => + useQuery({ + queryKey: queryKeys.playgrounds.detail(projectId, playgroundId ?? ""), + queryFn: () => playgroundApi.getById(playgroundId!, projectId), + enabled: !!playgroundId && !!projectId, + retry: false, + }); + +export const usePlaygroundsByOwner = ( + nodeId: string | null, + projectId: string +) => + useQuery({ + queryKey: queryKeys.playgrounds.byOwner(projectId, nodeId ?? ""), + queryFn: () => + nodeId ? playgroundApi.getByOwnerNodeId(nodeId, projectId) : Promise.resolve([]), + enabled: !!nodeId && !!projectId, + retry: false, + }); diff --git a/src/frontend/src/types/playground.ts b/src/frontend/src/types/playground.ts new file mode 100644 index 00000000..952c505a --- /dev/null +++ b/src/frontend/src/types/playground.ts @@ -0,0 +1,49 @@ +export interface PlaygroundOwnerFields { + owner_function?: string | null; + owner_class?: string | null; + owner_file?: string | null; + owner_folder?: string | null; +} + +export interface Playground { + id: string; + name: string; + description: string; + relative_path: string; + code: string; + executable_path?: string | null; + filename?: string | null; + owner_function?: string | null; + owner_class?: string | null; + owner_file?: string | null; + owner_folder?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface CreatePlaygroundPayload extends PlaygroundOwnerFields { + name: string; + description?: string; + relative_path: string; + code: string; + executable_path?: string | null; + filename?: string | null; +} + +export interface UpdatePlaygroundPayload { + name?: string; + description?: string; + relative_path?: string; + code?: string; + executable_path?: string | null; + filename?: string | null; +} + +export interface RunPlaygroundCodePayload { + playground_id: string; +} + +export interface RunPlaygroundCodeResponse { + response: string; + has_error: boolean; +} From 9d8546d899c046b83def832b636a1e8ede5c7d1f Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 01:11:46 +0300 Subject: [PATCH 04/49] playground api connected --- .../components/PlaygroundFormDialog.tsx | 112 +++++++ .../Playground/hooks/usePlaygroundState.ts | 273 ++++++++++++++++-- .../Sandbox/features/Playground/index.tsx | 51 +++- .../Main/components/Sandbox/index.tsx | 6 +- .../src/services/playground/mutations.ts | 11 +- 5 files changed, 409 insertions(+), 44 deletions(-) create mode 100644 src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/components/PlaygroundFormDialog.tsx diff --git a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/components/PlaygroundFormDialog.tsx b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/components/PlaygroundFormDialog.tsx new file mode 100644 index 00000000..bd2632c4 --- /dev/null +++ b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/components/PlaygroundFormDialog.tsx @@ -0,0 +1,112 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +export interface PlaygroundFormValues { + name: string; + description: string; + relative_path: string; + executable_path: string; + filename: string; +} + +interface PlaygroundFormDialogProps { + open: boolean; + isUpdate: boolean; + values: PlaygroundFormValues; + isSubmitting?: boolean; + onOpenChange: (open: boolean) => void; + onChange: (next: PlaygroundFormValues) => void; + onSubmit: () => void; +} + +export default function PlaygroundFormDialog({ + open, + isUpdate, + values, + isSubmitting = false, + onOpenChange, + onChange, + onSubmit, +}: PlaygroundFormDialogProps) { + const title = isUpdate ? "Update Playground" : "Create Playground"; + const submitLabel = isUpdate ? "Update" : "Create"; + + return ( + + + + {title} + +
+
+ + onChange({ ...values, name: e.target.value })} + /> +
+
+ + + onChange({ ...values, description: e.target.value }) + } + /> +
+
+ + + onChange({ ...values, relative_path: e.target.value }) + } + /> +
+
+ + + onChange({ ...values, executable_path: e.target.value }) + } + /> +
+
+ + onChange({ ...values, filename: e.target.value })} + /> +
+
+ + + + +
+
+ ); +} diff --git a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/hooks/usePlaygroundState.ts b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/hooks/usePlaygroundState.ts index 0e300955..ce340f1a 100644 --- a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/hooks/usePlaygroundState.ts +++ b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/hooks/usePlaygroundState.ts @@ -1,60 +1,265 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { type SelectableItem } from "../components/SelectableList"; -import { useRunCode } from "@/features/Dashboard/features/Main/hooks/usePlayGround"; import useProjectStore from "@/features/Dashboard/store/useProjectStore"; +import type { AnyNodeTree, CallNodeTree } from "@/types/project"; +import type { CreatePlaygroundPayload } from "@/types/playground"; +import { + useCreatePlayground, + useDeletePlayground, + usePlaygroundsByOwner, + useRunPlaygroundCode, + useUpdatePlayground, +} from "@/services/playground"; +import { toast } from "sonner"; +import type { PlaygroundFormValues } from "../components/PlaygroundFormDialog"; /** * Hook to manage Playground internal state. */ -export function usePlaygroundState(onRunningChange?: (isRunning: boolean) => void) { +const DEFAULT_CODE = "# write your code here"; + +function toEffectiveNode( + selectedNode: AnyNodeTree | null | undefined, + secondarySelectedNode: AnyNodeTree | null | undefined +) { + if (secondarySelectedNode) { + if ((secondarySelectedNode as CallNodeTree).target) { + return (secondarySelectedNode as CallNodeTree).target; + } + return secondarySelectedNode; + } + if (selectedNode?.node_type === "call") { + return (selectedNode as CallNodeTree).target; + } + return selectedNode; +} + +function getOwnerFieldPayload( + nodeId: string, + nodeType: string +): Pick< + CreatePlaygroundPayload, + "owner_function" | "owner_class" | "owner_file" | "owner_folder" +> { + if (nodeType === "function") return { owner_function: nodeId }; + if (nodeType === "class") return { owner_class: nodeId }; + if (nodeType === "file") return { owner_file: nodeId }; + if (nodeType === "folder") return { owner_folder: nodeId }; + throw new Error("Playground owner must be function, class, file, or folder"); +} + +export function usePlaygroundState( + tabId: string, + onRunningChange?: (isRunning: boolean) => void +) { const [code, setCode] = useState("# write your code here"); - const [items, setItems] = useState([ - { id: "playground", label: "Playground" }, - ]); - const [selectedId, setSelectedId] = useState("playground"); + const [selectedId, setSelectedId] = useState(); const [output, setOutput] = useState(""); const [settingsOpen, setSettingsOpen] = useState(false); const [examplesPath, setExamplesPath] = useState(""); const [commandPrefix, setCommandPrefix] = useState("python"); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingPlaygroundId, setEditingPlaygroundId] = useState( + null + ); + const [formValues, setFormValues] = useState({ + name: "", + description: "", + relative_path: "", + executable_path: "", + filename: "playground.py", + }); const project = useProjectStore((s) => s.projectData); - const runCode = useRunCode(project?.id); + const selectedNode = useProjectStore((s) => s.selectedNode[tabId]); + const secondarySelectedNode = useProjectStore( + (s) => s.secondarySelectedNode[tabId] + ); + const projectId = project?.id ?? ""; + const effectiveNode = useMemo( + () => toEffectiveNode(selectedNode, secondarySelectedNode), + [selectedNode, secondarySelectedNode] + ); + const ownerNodeId = effectiveNode?.id ?? null; + + const { data: playgrounds = [] } = usePlaygroundsByOwner(ownerNodeId, projectId); + const createPlayground = useCreatePlayground(projectId); + const updatePlayground = useUpdatePlayground(projectId); + const deletePlayground = useDeletePlayground(projectId); + const runPlaygroundCode = useRunPlaygroundCode(projectId); + const codeUpdateTimer = useRef(null); + const didHydrateCodeRef = useRef(false); + + const items = useMemo( + () => + playgrounds.map((item) => ({ + id: item.id, + label: item.name || item.filename || "Untitled playground", + })), + [playgrounds] + ); + + const selectedPlayground = useMemo( + () => playgrounds.find((item) => item.id === selectedId) ?? null, + [playgrounds, selectedId] + ); + + useEffect(() => { + if (!playgrounds.length) { + setSelectedId(undefined); + setCode(DEFAULT_CODE); + didHydrateCodeRef.current = false; + return; + } + + const currentExists = playgrounds.some((item) => item.id === selectedId); + if (!selectedId || !currentExists) { + setSelectedId(playgrounds[0].id); + } + }, [playgrounds, selectedId]); + + useEffect(() => { + if (!selectedPlayground) return; + setCode(selectedPlayground.code || DEFAULT_CODE); + didHydrateCodeRef.current = true; + }, [selectedPlayground?.id]); const handleRun = useCallback(async () => { + if (!selectedPlayground) { + setOutput("Create or select a playground first."); + return; + } onRunningChange?.(true); - const relativeFile = selectedId === "playground" ? "playground.py" : `${selectedId}.py`; - const fullPath = `${examplesPath}/${relativeFile}`; - const runCommand = `${commandPrefix} ${fullPath}`; - setOutput(`Command: ${runCommand}`); + setOutput("Running playground..."); try { - const resp = await runCode.mutateAsync({ - code, - executable_path: null, - examples_path: examplesPath, - command_prefix: commandPrefix, - filename: relativeFile, + const resp = await runPlaygroundCode.mutateAsync({ + playground_id: selectedPlayground.id, }); - setOutput((prev) => `${prev}\n${resp.response}`); + setOutput(resp.response || ""); } catch { - setOutput((prev) => `${prev}\nError running code`); + setOutput("Error running code"); } finally { onRunningChange?.(false); } - }, [code, selectedId, examplesPath, commandPrefix, runCode, onRunningChange]); + }, [onRunningChange, runPlaygroundCode, selectedPlayground]); + + useEffect(() => { + if (!selectedPlayground || !didHydrateCodeRef.current) return; + if (code === (selectedPlayground.code || "")) return; + + if (codeUpdateTimer.current) { + window.clearTimeout(codeUpdateTimer.current); + } + codeUpdateTimer.current = window.setTimeout(() => { + void updatePlayground.mutateAsync({ + playgroundId: selectedPlayground.id, + payload: { code }, + }); + }, 500); + + return () => { + if (codeUpdateTimer.current) { + window.clearTimeout(codeUpdateTimer.current); + } + }; + }, [code, selectedPlayground, updatePlayground]); const handleAddSnippet = useCallback(() => { - const newId = `snippet-${Math.random().toString(36).slice(2, 8)}`; - const newItem = { id: newId, label: `Snippet ${items.length}` }; - setItems((prev) => [...prev, newItem]); - setSelectedId(newId); - }, [items.length]); + if (!effectiveNode?.id || !effectiveNode?.node_type) { + toast.error("Select a function, class, file, or folder first."); + return; + } + if ( + !["function", "class", "file", "folder"].includes(effectiveNode.node_type) + ) { + toast.error("Playground owner must be function, class, file, or folder."); + return; + } + setEditingPlaygroundId(null); + setFormValues({ + name: "", + description: "", + relative_path: "", + executable_path: "", + filename: "playground.py", + }); + setDialogOpen(true); + }, [effectiveNode]); + + const handleEditSnippet = useCallback(() => { + if (!selectedPlayground) return; + setEditingPlaygroundId(selectedPlayground.id); + setFormValues({ + name: selectedPlayground.name || "", + description: selectedPlayground.description || "", + relative_path: selectedPlayground.relative_path || "", + executable_path: selectedPlayground.executable_path || "", + filename: selectedPlayground.filename || "", + }); + setDialogOpen(true); + }, [selectedPlayground]); + + const handleSubmitDialog = useCallback(async () => { + const name = formValues.name.trim(); + const relativePath = formValues.relative_path.trim(); + if (!name || !relativePath) { + toast.error("Name and relative path are required."); + return; + } + + if (editingPlaygroundId) { + await updatePlayground.mutateAsync({ + playgroundId: editingPlaygroundId, + payload: { + name, + description: formValues.description, + relative_path: relativePath, + executable_path: formValues.executable_path || null, + filename: formValues.filename || null, + }, + }); + setDialogOpen(false); + return; + } + + if (!effectiveNode?.id || !effectiveNode?.node_type) { + toast.error("Select a valid owner node first."); + return; + } + + let ownerFieldPayload: ReturnType; + try { + ownerFieldPayload = getOwnerFieldPayload(effectiveNode.id, effectiveNode.node_type); + } catch (error) { + toast.error((error as Error).message); + return; + } + + const created = await createPlayground.mutateAsync({ + name, + description: formValues.description, + relative_path: relativePath, + executable_path: formValues.executable_path || null, + filename: formValues.filename || null, + code: code || DEFAULT_CODE, + ...ownerFieldPayload, + }); + setSelectedId(created.id); + setDialogOpen(false); + }, [ + code, + createPlayground, + editingPlaygroundId, + effectiveNode, + formValues, + updatePlayground, + ]); const handleRemoveSnippet = useCallback((id: string) => { - if (id === "playground") return; - setItems((prev) => prev.filter((x) => x.id !== id)); - if (selectedId === id) setSelectedId("playground"); - }, [selectedId]); + void deletePlayground.mutateAsync(id); + if (selectedId === id) setSelectedId(undefined); + }, [deletePlayground, selectedId]); return { code, @@ -70,8 +275,16 @@ export function usePlaygroundState(onRunningChange?: (isRunning: boolean) => voi setExamplesPath, commandPrefix, setCommandPrefix, + dialogOpen, + setDialogOpen, + formValues, + setFormValues, + editingPlaygroundId, + isDialogSubmitting: createPlayground.isPending || updatePlayground.isPending, handleRun, handleAddSnippet, + handleEditSnippet, + handleSubmitDialog, handleRemoveSnippet, }; } diff --git a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/index.tsx b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/index.tsx index 14ea20b8..a7ae1a22 100644 --- a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/index.tsx +++ b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/features/Playground/index.tsx @@ -8,8 +8,9 @@ import { ResizablePanelGroup, ResizableHandle, } from "@/components/ui/resizable"; -import { PlusIcon } from "lucide-react"; +import { PencilIcon, PlusIcon } from "lucide-react"; import SettingsDialog from "./components/SettingsDialog"; +import PlaygroundFormDialog from "./components/PlaygroundFormDialog"; export type PlayGroundHandle = { run: () => void; @@ -17,6 +18,7 @@ export type PlayGroundHandle = { }; interface PlaygroundProps { + tabId: string; onRunningChange?: (isRunning: boolean) => void; } @@ -25,7 +27,7 @@ interface PlaygroundProps { * Orchestrates the code execution environment. */ const Playground = forwardRef( - ({ onRunningChange }, ref) => { + ({ tabId, onRunningChange }, ref) => { const { code, setCode, @@ -39,10 +41,18 @@ const Playground = forwardRef( setExamplesPath, commandPrefix, setCommandPrefix, + dialogOpen, + setDialogOpen, + formValues, + setFormValues, + editingPlaygroundId, + isDialogSubmitting, handleRun, handleAddSnippet, + handleEditSnippet, + handleSubmitDialog, handleRemoveSnippet, - } = usePlaygroundState(onRunningChange); + } = usePlaygroundState(tabId, onRunningChange); const language = useMemo(() => detectLanguage("snippet.py"), []); @@ -61,13 +71,23 @@ const Playground = forwardRef(
Files
- +
+ + +
( onChangeExamplesPath={setExamplesPath} onChangeCommandPrefix={setCommandPrefix} /> + { + void handleSubmitDialog(); + }} + />
); } diff --git a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/index.tsx b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/index.tsx index f9e4773a..cc46c4c2 100644 --- a/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/index.tsx +++ b/src/frontend/src/features/Dashboard/features/Main/components/Sandbox/index.tsx @@ -72,7 +72,11 @@ export default function Sandbox({ tabId }: { tabId: string }) { value="playground" className="m-0 h-full overflow-hidden outline-none" > - + { }); }; -export const useUpdatePlayground = (projectId: string, playgroundId: string) => { +export const useUpdatePlayground = (projectId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (payload: UpdatePlaygroundPayload) => - playgroundApi.update(playgroundId, payload, projectId), + mutationFn: ({ + playgroundId, + payload, + }: { + playgroundId: string; + payload: UpdatePlaygroundPayload; + }) => playgroundApi.update(playgroundId, payload, projectId), onSuccess: () => { invalidatePlaygroundQueries(queryClient); }, From 477a634d3bd1f392ca044edef72fdb5fd5a213b1 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 00:30:03 +0300 Subject: [PATCH 05/49] docker fixed --- src/backend/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/docker-compose.yml b/src/backend/docker-compose.yml index cff2d8be..04799fa5 100755 --- a/src/backend/docker-compose.yml +++ b/src/backend/docker-compose.yml @@ -28,7 +28,7 @@ services: # terminusdb: # condition: service_healthy environment: - - TERMINUSDB_CONTENT_ENDPOINT=http://terminusdb:6363 + - TERMINUSDB_CONTENT_ENDPOINT=http://terminusdb:6363/api/index - TERMINUSDB_USER_FORWARD_HEADER=X-User-Forward - OPENAI_KEY=${OPENAI_KEY} volumes: From 42ffb0e3479a477f21413cc92d6bd706364980c5 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 17:35:18 +0300 Subject: [PATCH 06/49] basic structure added --- src/backend/app/agent/__init__.py | 0 src/backend/app/agent/graph/__init__.py | 0 src/backend/app/agent/graph/builder.py | 30 ++++++++++++++ src/backend/app/agent/graph/edges.py | 15 +++++++ src/backend/app/agent/graph/nodes.py | 35 ++++++++++++++++ src/backend/app/agent/graph/state.py | 40 +++++++++++++++++++ src/backend/app/agent/tools/__init__.py | 0 src/backend/app/agent/tools/base.py | 21 ++++++++++ src/backend/app/agent/tools/tool_card.py | 23 +++++++++++ src/backend/app/agent/tools/tool_registry.py | 23 +++++++++++ src/backend/app/agent/workflows/__init__.py | 0 src/backend/app/agent/workflows/base.py | 13 ++++++ .../code_elements/play_ground_repo.py | 1 - src/backend/pyproject.toml | 3 ++ 14 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/backend/app/agent/__init__.py create mode 100644 src/backend/app/agent/graph/__init__.py create mode 100644 src/backend/app/agent/graph/builder.py create mode 100644 src/backend/app/agent/graph/edges.py create mode 100644 src/backend/app/agent/graph/nodes.py create mode 100644 src/backend/app/agent/graph/state.py create mode 100644 src/backend/app/agent/tools/__init__.py create mode 100644 src/backend/app/agent/tools/base.py create mode 100644 src/backend/app/agent/tools/tool_card.py create mode 100644 src/backend/app/agent/tools/tool_registry.py create mode 100644 src/backend/app/agent/workflows/__init__.py create mode 100644 src/backend/app/agent/workflows/base.py diff --git a/src/backend/app/agent/__init__.py b/src/backend/app/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/graph/__init__.py b/src/backend/app/agent/graph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/graph/builder.py b/src/backend/app/agent/graph/builder.py new file mode 100644 index 00000000..72730d8e --- /dev/null +++ b/src/backend/app/agent/graph/builder.py @@ -0,0 +1,30 @@ + +from langgraph.graph import StateGraph, END +from .state import AgentState +from .nodes import planner, executor, reflector, responder +from .edges import after_planner, after_reflector + + +def build_agent_graph() -> StateGraph: + graph = StateGraph(AgentState) + + graph.add_node("planner", planner) + graph.add_node("executor", executor) + graph.add_node("reflector", reflector) + graph.add_node("responder", responder) + + graph.set_entry_point("planner") + + graph.add_conditional_edges("planner", after_planner, { + "executor": "executor", + "responder": "responder", + "end": END, + }) + graph.add_edge("executor", "reflector") + graph.add_conditional_edges("reflector", after_reflector, { + "responder": "responder", + "planner": "planner", + }) + graph.add_edge("responder", END) + + return graph.compile() diff --git a/src/backend/app/agent/graph/edges.py b/src/backend/app/agent/graph/edges.py new file mode 100644 index 00000000..d4b5d7cd --- /dev/null +++ b/src/backend/app/agent/graph/edges.py @@ -0,0 +1,15 @@ +from app.agent.graph.state import AgentState + + +def after_planner(state: AgentState) -> str: + if state["should_finish"]: + return "end" + if state["selected_tool"]: + return "executor" + return "responder" + + +def after_reflector(state: AgentState) -> str: + if state["should_finish"] or state["iteration_count"] >= state["max_iterations"]: + return "responder" + return "planner" diff --git a/src/backend/app/agent/graph/nodes.py b/src/backend/app/agent/graph/nodes.py new file mode 100644 index 00000000..2de9cd29 --- /dev/null +++ b/src/backend/app/agent/graph/nodes.py @@ -0,0 +1,35 @@ +from app.agent.graph.state import AgentState + + +async def planner(state: AgentState) -> AgentState: + """ + Given messages + context, produce a plan. + Decides: use a tool, answer directly, or give up. + Populates: plan, selected_tool, tool_input. + """ + ... + + +async def executor(state: AgentState) -> AgentState: + """ + Look up selected_tool in ToolRegistry, call execute(). + Populates: tool_results (appends). + """ + ... + + +async def reflector(state: AgentState) -> AgentState: + """ + Review tool results. Decide if we have enough context to answer, + or if we need another tool call. + Populates: should_finish, context_docs. + """ + ... + + +async def responder(state: AgentState) -> AgentState: + """ + Generate the final response from accumulated context. + Appends an assistant message to messages. + """ + ... diff --git a/src/backend/app/agent/graph/state.py b/src/backend/app/agent/graph/state.py new file mode 100644 index 00000000..a25212e6 --- /dev/null +++ b/src/backend/app/agent/graph/state.py @@ -0,0 +1,40 @@ +from typing import TypedDict, Annotated, Sequence, Optional +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages + + +class ToolResult(TypedDict): + tool_name: str + tool_input: dict + output: str # serialized result + error: Optional[str] + + +class AgentState(TypedDict): + """Shared state flowing through the LangGraph.""" + messages: Annotated[Sequence[BaseMessage], add_messages] + + # Planning + plan: Optional[str] # high-level plan text + current_step: int # index in plan steps + + # Tool execution + selected_tool: Optional[str] + tool_input: Optional[dict] + tool_results: list[ToolResult] + + # Context + # retrieved graph nodes / vector results + context_docs: list[dict] + token_budget_remaining: int + + # Control + iteration_count: int + max_iterations: int + should_finish: bool + + # Workflow-specific (used by subgraphs) + target_node_id: Optional[str] # node being documented + traversal_direction: Optional[str] # "up" | "down" + # "description" | "documentation" | "both" + generation_mode: Optional[str] diff --git a/src/backend/app/agent/tools/__init__.py b/src/backend/app/agent/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/tools/base.py b/src/backend/app/agent/tools/base.py new file mode 100644 index 00000000..98306b59 --- /dev/null +++ b/src/backend/app/agent/tools/base.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from typing import Any +from app.agent.tools.tool_card import ToolCard + + +class BaseTool(ABC): + """Abstract base class for all agent tools.""" + + @abstractmethod + def get_card(self) -> ToolCard: + """Return the tool's self-describing metadata.""" + ... + + @abstractmethod + async def execute(self, **kwargs) -> Any: + """Run the tool with validated inputs. Returns structured output.""" + ... + + def validate_inputs(self, **kwargs) -> dict: + """Optional input validation hook. Override to add custom checks.""" + return kwargs diff --git a/src/backend/app/agent/tools/tool_card.py b/src/backend/app/agent/tools/tool_card.py new file mode 100644 index 00000000..f28c52a8 --- /dev/null +++ b/src/backend/app/agent/tools/tool_card.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Optional + + +class ToolCard(BaseModel): + """Self-describing metadata for a tool (OctoTools-style tool card).""" + name: str # e.g. "graph_search" + description: str # Human-readable purpose + version: str = "1.0.0" + + # Schema for inputs / outputs + input_schema: dict # JSON-Schema dict for execute() kwargs + output_type: str # Human-readable output type description + + # Usage hints (fed to the planner LLM) + demo_commands: list[dict] = [] # Example invocations + limitations: Optional[str] = None + best_practice: Optional[str] = None + + # Runtime flags + requires_llm: bool = False # Does this tool need an LLM engine internally? + requires_vectorlink: bool = False # Needs VectorLink client? + requires_db: bool = False # Needs TerminusDB client? diff --git a/src/backend/app/agent/tools/tool_registry.py b/src/backend/app/agent/tools/tool_registry.py new file mode 100644 index 00000000..7f6232fb --- /dev/null +++ b/src/backend/app/agent/tools/tool_registry.py @@ -0,0 +1,23 @@ +from app.agent.tools.base import BaseTool + + +class ToolRegistry: + """Discover, register, enable/disable tools at runtime.""" + + def __init__(self): + self._tools: dict[str, BaseTool] = {} + + def register(self, tool: BaseTool) -> None: + card = tool.get_card() + self._tools[card.name] = tool + + def get(self, name: str) -> BaseTool: + return self._tools[name] + + def list_cards(self, enabled_only: bool = True) -> list[ToolCard]: + """Return tool cards (useful for feeding to the LLM planner).""" + return [t.get_card() for t in self._tools.values()] + + def auto_discover(self, package_path: str = "app.agent.tools") -> None: + """Walk subpackages, import any BaseTool subclass, register it.""" + ... diff --git a/src/backend/app/agent/workflows/__init__.py b/src/backend/app/agent/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/workflows/base.py b/src/backend/app/agent/workflows/base.py new file mode 100644 index 00000000..b168ab86 --- /dev/null +++ b/src/backend/app/agent/workflows/base.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class BaseWorkflow(ABC): + """Abstract base for all deterministic workflows.""" + name: str + description: str + + @abstractmethod + async def run(self, **kwargs) -> Any: + """Run the workflow.""" + ... diff --git a/src/backend/app/core/repository/code_elements/play_ground_repo.py b/src/backend/app/core/repository/code_elements/play_ground_repo.py index bac4d362..f01098f5 100644 --- a/src/backend/app/core/repository/code_elements/play_ground_repo.py +++ b/src/backend/app/core/repository/code_elements/play_ground_repo.py @@ -67,4 +67,3 @@ async def get_by_owner_field( return [] return [row["playground_doc"] for row in result.get("bindings", [])] - diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 62c721c3..f1872ca5 100755 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "trio>=0.32.0", "python-slugify>=8.0.4", "coverage>=7.13.4", + "litellm>=1.82.4", + "langgraph>=1.1.2", + "langchain>=1.2.12", ] [project.optional-dependencies] From 31623aa3cbcd1453d4770e11db798a7866800172 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 17:35:24 +0300 Subject: [PATCH 07/49] loc added --- uv.lock | 983 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 983 insertions(+) diff --git a/uv.lock b/uv.lock index a920f1e5..6be7f7cf 100755 --- a/uv.lock +++ b/uv.lock @@ -150,6 +150,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -235,7 +244,10 @@ dependencies = [ { name = "httpx" }, { name = "jedi" }, { name = "kuzu" }, + { name = "langchain" }, + { name = "langgraph" }, { name = "libcst" }, + { name = "litellm" }, { name = "loguru" }, { name = "parso" }, { name = "pathspec" }, @@ -277,7 +289,10 @@ requires-dist = [ { name = "httpx", marker = "extra == 'test'" }, { name = "jedi", specifier = ">=0.19.2" }, { name = "kuzu", specifier = ">=0.11.3" }, + { name = "langchain", specifier = ">=1.2.12" }, + { name = "langgraph", specifier = ">=1.1.2" }, { name = "libcst", specifier = ">=1.7.0" }, + { name = "litellm", specifier = ">=1.82.4" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "parso", specifier = ">=0.8.5" }, { name = "pathspec", specifier = ">=0.12.1" }, @@ -533,6 +548,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -602,6 +626,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/19/ebaf4486722822d1e21b64000ed0b1ccec8d56719174c4819c41d2b755b4/fastapi_jsonrpc-3.4.1-py3-none-any.whl", hash = "sha256:5c3466a342701863440928a3f7767a77fc7ee5eaa82b00c867465af3dc369eba", size = 20026 }, ] +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164 }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837 }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370 }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766 }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105 }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564 }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659 }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430 }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894 }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374 }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550 }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720 }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024 }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679 }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862 }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278 }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788 }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819 }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546 }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921 }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559 }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539 }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600 }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069 }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543 }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798 }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283 }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627 }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778 }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605 }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837 }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532 }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457 }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -691,6 +765,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, ] +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505 }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -739,6 +822,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] +[[package]] +name = "hf-xet" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/06/e8cf74c3c48e5485c7acc5a990d0d8516cdfb5fdf80f799174f1287cc1b5/hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4", size = 3796125 }, + { url = "https://files.pythonhosted.org/packages/66/d4/b73ebab01cbf60777323b7de9ef05550790451eb5172a220d6b9845385ec/hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81", size = 3555985 }, + { url = "https://files.pythonhosted.org/packages/ff/e7/ded6d1bd041c3f2bca9e913a0091adfe32371988e047dd3a68a2463c15a2/hf_xet-1.4.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4642a6cf249c09da8c1f87fe50b24b2a3450b235bf8adb55700b52f0ea6e2eb6", size = 4212085 }, + { url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266 }, + { url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513 }, + { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287 }, + { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574 }, + { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760 }, + { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493 }, + { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127 }, + { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788 }, + { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315 }, + { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306 }, + { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826 }, + { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113 }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339 }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664 }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422 }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847 }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843 }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751 }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149 }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426 }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -767,6 +882,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "huggingface-hub" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/a8/94ccc0aec97b996a3a68f3e1fa06a4bd7185dd02bf22bfba794a0ade8440/huggingface_hub-1.7.1.tar.gz", hash = "sha256:be38fe66e9b03c027ad755cb9e4b87ff0303c98acf515b5d579690beb0bf3048", size = 722097 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/75/ca21955d6117a394a482c7862ce96216239d0e3a53133ae8510727a8bcfa/huggingface_hub-1.7.1-py3-none-any.whl", hash = "sha256:38c6cce7419bbde8caac26a45ed22b0cea24152a8961565d70ec21f88752bfaa", size = 616308 }, +] + [[package]] name = "idna" version = "3.10" @@ -863,6 +998,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958 }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597 }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821 }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163 }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709 }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735 }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814 }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990 }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021 }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024 }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424 }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818 }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897 }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507 }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560 }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232 }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727 }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799 }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120 }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664 }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543 }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262 }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630 }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602 }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939 }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616 }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850 }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551 }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950 }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852 }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804 }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787 }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880 }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702 }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319 }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289 }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165 }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634 }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933 }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842 }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108 }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027 }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199 }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438 }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774 }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238 }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892 }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309 }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607 }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986 }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756 }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196 }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215 }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152 }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016 }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024 }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337 }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395 }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169 }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808 }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384 }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768 }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898 }, +] + [[package]] name = "jsonpickle" version = "4.1.1" @@ -872,6 +1091,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125 }, ] +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "kuzu" version = "0.11.3" @@ -896,6 +1151,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/be/5b4ff168718165c2ff5848ab79e22ecce72ad00522afee6820d390cb0753/kuzu-0.11.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c7ec822906bdee154eb38d93e64f184d8f94b30bbeaceaa252725f2b9efab3", size = 7620394 }, ] +[[package]] +name = "langchain" +version = "1.2.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/1d/1af2fc0ac084d4781778b7846b1aed62e05006bf2d73fdf84ac3a8f5225c/langchain-1.2.12.tar.gz", hash = "sha256:ed705b5b293799f7e3e394387f398a1b71707542758283206c8c21415759d991", size = 566444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/51/09bb1cfb0b57ae9440ca56cc576e4dc792f83d030eef7637d2c516dcb0a0/langchain-1.2.12-py3-none-any.whl", hash = "sha256:60eff184b8f92c2610f5a4c9a97ad339a891adb01901e83e4df8e6c9c69cf852", size = 112373 }, +] + +[[package]] +name = "langchain-core" +version = "1.2.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/da/075720d37ebc668f48743bd540b047b2b08b8ba22b46d8f61166c5ad1d1c/langchain_core-1.2.19.tar.gz", hash = "sha256:87fa82c3eb4cc3d7a65f574cb447b5df09ec2131c8c2a0a02d4737ad02685438", size = 836647 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/cb/8704b2a22c0987627ed29464d23a45fb15e10a28fb482f4d84c3bddcbf27/langchain_core-1.2.19-py3-none-any.whl", hash = "sha256:6e74cb0fb443a8046ee298c05c99b67abe54cc57fcbc6d1cd3b0f2485ee47574", size = 503456 }, +] + +[[package]] +name = "langgraph" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/a8/8494057db9149eb850258e5d4ae961a8dbda9a283e56e1b957393d9df0cd/langgraph-1.1.2.tar.gz", hash = "sha256:c4385ce349823a590891b3f6b1c46b54f51d0134164056866e95034985f047c9", size = 544288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/38/3117cd90325635893a76132cdae74f5b1f53c93c33b3dc6124521cec9825/langgraph-1.1.2-py3-none-any.whl", hash = "sha256:5fd43c839ec2b5af564e9ae2d2d4f22ce0a006a0b58e800cc4e8de4dd9cbb643", size = 167543 }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/44/a8df45d1e8b4637e29789fa8bae1db022c953cc7ac80093cfc52e923547e/langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9", size = 158135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453 }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648 }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/cd/a019f1b1e97c519f2425593f9bccd3ac463a18fb5d2111cff59ce1ef62fe/langgraph_sdk-0.3.11.tar.gz", hash = "sha256:3640134835d89d2c7c8bb7de73bd10673d4b282db3ff0e2fdaf1cee9e50cb1eb", size = 190387 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/c8/b8d15d4b9a320a3f57a851030a371066b91dbd1420f097d3d0338da9adc9/langgraph_sdk-0.3.11-py3-none-any.whl", hash = "sha256:18905fd6248ade98b0995d859a98672d57c811fbfffc0d63d1c107a512351b26", size = 94887 }, +] + +[[package]] +name = "langsmith" +version = "0.7.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/c6/cbdc6638207f68a3c61ec0b64fa593f6b11de3170d03c852238c31b54960/langsmith-0.7.20.tar.gz", hash = "sha256:fa983a74f75648ee0e80d3f9751162b6f9a438896d5f9bdb6cba9abda451e234", size = 1134732 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/46/9294d4f49de6a8f08e8b83907713ca545459d87d474c6add15d31a36f5dc/langsmith-0.7.20-py3-none-any.whl", hash = "sha256:0162faf791ea48d69009a12a3da917468556b99cf5d5fcacbb8cda064262e118", size = 359314 }, +] + [[package]] name = "libcst" version = "1.7.0" @@ -921,6 +1285,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/9a/535a81bade997f98bc17c151b524c00eb12a6738e9cbaecea00fbcccb6b9/libcst-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:6137fe549bfbb017283c3cf85419eb0dfaa20a211ad6d525538a2494e248a84b", size = 2094937 }, ] +[[package]] +name = "litellm" +version = "1.82.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/79/b492be13542aebd62aafc0490e4d5d6e8e00ce54240bcabf5c3e46b1a49b/litellm-1.82.4.tar.gz", hash = "sha256:9c52b1c0762cb0593cdc97b26a8e05004e19b03f394ccd0f42fac82eff0d4980", size = 17378196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ad/7eaa1121c6b191f2f5f2e8c7379823ece6ec83741a4b3c81b82fe2832401/litellm-1.82.4-py3-none-any.whl", hash = "sha256:d37c34a847e7952a146ed0e2888a24d3edec7787955c6826337395e755ad5c4b", size = 15559801 }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -934,6 +1321,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1009,6 +1408,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -1199,6 +1607,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/5e/3a6a3e90f35cea3853c45e5d5fb9b7192ce4384616f932cf7591298ab6e1/numpydoc-1.10.0-py3-none-any.whl", hash = "sha256:3149da9874af890bcc2a82ef7aae5484e5aa81cb2778f08e3c307ba6d963721b", size = 69255 }, ] +[[package]] +name = "openai" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533 }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545 }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224 }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154 }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548 }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000 }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686 }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812 }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440 }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386 }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853 }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130 }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818 }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923 }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007 }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089 }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390 }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189 }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106 }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363 }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007 }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667 }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832 }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373 }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307 }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695 }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099 }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806 }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914 }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986 }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045 }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391 }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188 }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097 }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364 }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076 }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705 }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855 }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386 }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295 }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720 }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152 }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814 }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997 }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985 }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038 }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618 }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186 }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738 }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569 }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166 }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498 }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518 }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462 }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559 }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661 }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194 }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778 }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592 }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164 }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516 }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539 }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459 }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577 }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717 }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183 }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814 }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634 }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139 }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578 }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539 }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493 }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579 }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721 }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170 }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816 }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232 }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1770,6 +2289,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574 }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426 }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200 }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765 }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093 }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455 }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037 }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194 }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846 }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516 }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278 }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068 }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416 }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297 }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408 }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311 }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285 }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051 }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842 }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083 }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412 }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101 }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311 }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876 }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632 }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320 }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152 }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398 }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282 }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382 }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541 }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984 }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509 }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429 }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422 }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175 }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044 }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056 }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743 }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633 }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862 }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788 }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184 }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137 }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682 }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735 }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497 }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295 }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275 }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176 }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528 }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373 }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859 }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813 }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705 }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734 }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871 }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825 }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548 }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444 }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546 }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986 }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518 }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464 }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553 }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289 }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156 }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215 }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925 }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701 }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899 }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727 }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366 }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936 }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779 }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010 }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1809,6 +2430,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127 }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -1818,6 +2452,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676 }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, +] + [[package]] name = "ruff" version = "0.15.0" @@ -1868,6 +2583,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/eb/76f3dad9f509ef744c925ef374d5b814fafbdf7d4c8da19772717edb21ad/shed-2025.6.1-py3-none-any.whl", hash = "sha256:a238f34be0f040bdd705e4cbdaf3f98cdd30db832c34ca542037c8462556eb2a", size = 36535 }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -2054,6 +2778,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747 }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, +] + [[package]] name = "terminusdb-client" version = "10.2.6" @@ -2082,6 +2815,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, ] +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728 }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049 }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008 }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665 }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230 }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688 }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694 }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802 }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995 }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948 }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986 }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222 }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097 }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117 }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309 }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712 }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725 }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875 }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451 }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794 }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777 }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188 }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978 }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271 }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216 }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860 }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567 }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067 }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473 }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855 }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022 }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736 }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908 }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706 }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667 }, +] + [[package]] name = "tokenize-rt" version = "6.2.0" @@ -2091,6 +2871,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004 }, ] +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -2138,6 +2944,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/bb/d43e5c75054e53efce310e79d63df0ac3f25e34c926be5dffb7d283fb2a8/typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1", size = 17605 }, ] +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085 }, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -2177,6 +2998,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, ] +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679 }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346 }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714 }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914 }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609 }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699 }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205 }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836 }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260 }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824 }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407 }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476 }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147 }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132 }, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -2266,6 +3109,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, ] +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744 }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816 }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035 }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914 }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163 }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411 }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883 }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392 }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898 }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655 }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001 }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431 }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617 }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534 }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876 }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738 }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821 }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127 }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975 }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241 }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471 }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936 }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440 }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990 }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689 }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068 }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495 }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620 }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542 }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880 }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956 }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072 }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409 }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736 }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833 }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348 }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070 }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907 }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839 }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304 }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930 }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787 }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916 }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799 }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044 }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754 }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846 }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343 }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074 }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388 }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614 }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024 }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541 }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305 }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848 }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142 }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547 }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214 }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290 }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795 }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955 }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072 }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579 }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854 }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965 }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484 }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162 }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007 }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956 }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401 }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083 }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586 }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526 }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898 }, +] + [[package]] name = "yarl" version = "1.22.0" @@ -2368,3 +3294,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50e wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738 }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436 }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019 }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012 }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148 }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652 }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993 }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806 }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659 }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933 }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008 }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517 }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292 }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237 }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922 }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276 }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679 }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735 }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440 }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070 }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001 }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120 }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230 }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173 }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736 }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368 }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022 }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889 }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952 }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054 }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936 }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232 }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671 }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887 }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658 }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849 }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095 }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751 }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818 }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402 }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108 }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248 }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123 }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591 }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513 }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118 }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940 }, +] From 1e91b8eea79e389120d0a8a17dbbafd429e5f4a0 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 18:05:36 +0300 Subject: [PATCH 08/49] basic structure extended --- src/backend/app/agent/models/__init__.py | 0 src/backend/app/agent/models/conversation.py | 57 ++++++++++++++++++++ src/backend/app/agent/models/task_status.py | 25 +++++++++ src/backend/app/agent/runner/__init__.py | 0 src/backend/app/agent/runner/executor.py | 34 ++++++++++++ src/backend/app/agent/runner/task_manager.py | 57 ++++++++++++++++++++ src/backend/app/main.py | 4 ++ 7 files changed, 177 insertions(+) create mode 100644 src/backend/app/agent/models/__init__.py create mode 100644 src/backend/app/agent/models/conversation.py create mode 100644 src/backend/app/agent/models/task_status.py create mode 100644 src/backend/app/agent/runner/__init__.py create mode 100644 src/backend/app/agent/runner/executor.py create mode 100644 src/backend/app/agent/runner/task_manager.py diff --git a/src/backend/app/agent/models/__init__.py b/src/backend/app/agent/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/models/conversation.py b/src/backend/app/agent/models/conversation.py new file mode 100644 index 00000000..90588cd8 --- /dev/null +++ b/src/backend/app/agent/models/conversation.py @@ -0,0 +1,57 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal +from datetime import datetime +from enum import Enum + + +class MessageRole(str, Enum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class TextPart(BaseModel): + type: Literal["text"] = "text" + text: str + + +class ToolCallPart(BaseModel): + """Agent-side tool call record (not shown to user directly).""" + type: Literal["tool_call"] = "tool_call" + tool_name: str + tool_input: dict + tool_output: Optional[str] = None + + +class EventPart(BaseModel): + """Replay event (mirrors frontend ReplayEvent).""" + type: Literal["event"] = "event" + at: int + event_type: str # "wait" | "click" | "focus" + payload: dict = {} + + +# Union of all part types +MessagePart = TextPart | ToolCallPart | EventPart + + +class ConversationMessage(BaseModel): + id: str + role: MessageRole + parts: list[MessagePart] + created_at: datetime = Field(default_factory=datetime.utcnow) + token_count: Optional[int] = None # tokens used by this message + model: Optional[str] = None # which LLM generated this + + +class ConversationSummary(BaseModel): + id: str + title: str + created_at: datetime + updated_at: datetime + message_count: int = 0 + + +class Conversation(ConversationSummary): + messages: list[ConversationMessage] = [] + metadata: dict = {} # arbitrary metadata (e.g. linked project, node_id) diff --git a/src/backend/app/agent/models/task_status.py b/src/backend/app/agent/models/task_status.py new file mode 100644 index 00000000..60ceafed --- /dev/null +++ b/src/backend/app/agent/models/task_status.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from datetime import datetime +from enum import Enum +from typing import Optional, Any + + +class TaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TaskStatus(BaseModel): + id: str + name: str + state: TaskState + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + progress: float = 0.0 # 0.0 → 1.0 + progress_message: str = "" + result: Optional[Any] = None + error: Optional[str] = None diff --git a/src/backend/app/agent/runner/__init__.py b/src/backend/app/agent/runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py new file mode 100644 index 00000000..fcfb35ef --- /dev/null +++ b/src/backend/app/agent/runner/executor.py @@ -0,0 +1,34 @@ +# agent/runner/executor.py + +from agent.runner.task_manager import TaskManager +from agent.graph.builder import build_agent_graph +from agent.workflows.base import BaseWorkflow + + +class AgentExecutor: + """High-level entry point for running agents and workflows as tasks.""" + + def __init__(self, task_manager: TaskManager): + self.task_manager = task_manager + + def run_workflow(self, workflow: BaseWorkflow, **kwargs) -> str: + """Submit a workflow for background execution.""" + return self.task_manager.submit( + name=f"workflow:{workflow.name}", + coro_factory=workflow.run, + **kwargs, + ) + + def run_agent_chat(self, conversation_id: str, message: str) -> str: + """Submit an agent chat turn for background execution.""" + async def _run_agent(**kw): + graph = build_agent_graph() + result = await graph.ainvoke(kw) + return result + + return self.task_manager.submit( + name=f"agent:chat:{conversation_id}", + coro_factory=_run_agent, + conversation_id=conversation_id, + message=message, + ) diff --git a/src/backend/app/agent/runner/task_manager.py b/src/backend/app/agent/runner/task_manager.py new file mode 100644 index 00000000..466b8e7c --- /dev/null +++ b/src/backend/app/agent/runner/task_manager.py @@ -0,0 +1,57 @@ +import asyncio +import uuid +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Coroutine, Optional + +from app.agent.models.task_status import TaskStatus, TaskState + + +class TaskManager: + + def __init__(self): + self._tasks: dict[str, TaskStatus] = {} + self._asyncio_tasks: dict[str, asyncio.Task] = {} + + def submit(self, name: str, coro_factory: Callable[..., Any], **kwargs) -> str: + task_id = str(uuid.uuid4()) + status = TaskStatus( + id=task_id, + name=name, + state=TaskState.PENDING, + created_at=datetime.utcnow(), + ) + self._tasks[task_id] = status + + async def _wrapper(): + status.state = TaskState.RUNNING + status.started_at = datetime.utcnow() + try: + result = await coro_factory(**kwargs) + status.state = TaskState.COMPLETED + status.result = result + except asyncio.CancelledError: + status.state = TaskState.CANCELLED + except Exception as e: + status.state = TaskState.FAILED + status.error = str(e) + finally: + status.finished_at = datetime.utcnow() + + loop = asyncio.get_running_loop() + atask = loop.create_task(_wrapper()) + self._asyncio_tasks[task_id] = atask + return task_id + + def get_status(self, task_id: str) -> Optional[TaskStatus]: + return self._tasks.get(task_id) + + def cancel(self, task_id: str) -> bool: + atask = self._asyncio_tasks.get(task_id) + if atask and not atask.done(): + atask.cancel() + return True + return False + + def list_tasks(self) -> list[TaskStatus]: + return list(self._tasks.values()) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 5bf3f0c7..036bf9e2 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.socket.manager import get_socket_manager +from app.agent.runner.task_manager import TaskManager from .api import root from .db.client import get_terminus_client, close_db_client @@ -29,7 +30,10 @@ async def lifespan(app: FastAPI): watcher_service.set_event_loop( asyncio.get_running_loop() ) + task_manager = TaskManager() + app.state.watcher_service = watcher_service + app.state.task_manager = task_manager # Init Socket Manager (creates the server instance) _ = get_socket_manager() From 57d49d73faac957536642dc0cbbf83d9fe058989 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 22:16:16 +0300 Subject: [PATCH 09/49] save --- src/backend/app/agent/context/__init__.py | 0 .../app/agent/context/context_builder.py | 57 ++++++++++++++++ .../app/agent/context/graph_traversal.py | 41 ++++++++++++ .../app/agent/context/token_tracker.py | 38 +++++++++++ .../app/agent/context/vectorlink_client.py | 60 +++++++++++++++++ src/backend/app/agent/tools/tool_registry.py | 1 + .../app/agent/workflows/description_gen.py | 66 +++++++++++++++++++ 7 files changed, 263 insertions(+) create mode 100644 src/backend/app/agent/context/__init__.py create mode 100644 src/backend/app/agent/context/context_builder.py create mode 100644 src/backend/app/agent/context/graph_traversal.py create mode 100644 src/backend/app/agent/context/token_tracker.py create mode 100644 src/backend/app/agent/context/vectorlink_client.py create mode 100644 src/backend/app/agent/workflows/description_gen.py diff --git a/src/backend/app/agent/context/__init__.py b/src/backend/app/agent/context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/app/agent/context/context_builder.py b/src/backend/app/agent/context/context_builder.py new file mode 100644 index 00000000..9cb51ecc --- /dev/null +++ b/src/backend/app/agent/context/context_builder.py @@ -0,0 +1,57 @@ +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.context.vectorlink_client import VectorLinkClient +from app.agent.context.token_tracker import TokenTracker + + +class ContextBUilder: + """Assemble prompt context from multiple sources within a token budget.""" + + def __init__(self, + graph: GraphTraversal, + vectorlink: VectorLinkClient, + budget: TokenTracker): + self.graph = graph + self.vectorlink = vectorlink + self.budget = budget + + async def build_context( + self, + *, + node_id: str | None = None, + query: str | None = None, + include_code: bool = False, + traversal_direction: str = "down", + traversal_depth: int = 2, + vector_top_k: int = 5, + ): + """ + Build context by: + 1. Graph traversal from node_id (if provided) + 2. Vector search for query (if provided) + 3. Merge, deduplicate, rank by relevance + 4. Truncate to fit token budget + 5. Optionally attach code content + """ + context_items = [] + + # Step 1: Graph traversal + if node_id: + if traversal_direction == "down": + nodes = await self.graph.traverse_down(node_id, traversal_depth) + else: + nodes = await self.graph.traverse_up(node_id, traversal_depth) + context_items.extend(nodes) + + # Step 2: Vector search + if query: + results = await self.vectorlink.search( + db="...", # from ProjectUoW + query=query, + top_k=vector_top_k, + ) + context_items.extend(results) + + # Step 3-5: Deduplicate, budget-check, enrich + ... + + return context_items diff --git a/src/backend/app/agent/context/graph_traversal.py b/src/backend/app/agent/context/graph_traversal.py new file mode 100644 index 00000000..f3717447 --- /dev/null +++ b/src/backend/app/agent/context/graph_traversal.py @@ -0,0 +1,41 @@ +from app.db.context import ProjectUoW +from app.core.model.nodes import BaseNode + + +class GraphTraversal: + """Walk the TerminusDB graph up or down from a starting node.""" + + def __init__(self, uow: ProjectUoW): + self.repos = uow.get_project_repos() + + async def traverse_down( + self, + node_id: str, + max_depth: int = 3, + node_types: list[str] | None = None, + ) -> list[dict]: + """ + BFS/DFS downward from node_id. + Returns a flat list of node dicts with depth metadata. + Respects node_types filter (e.g. ["FunctionSchema", "ClassSchema"]). + """ + ... + + async def traverse_up( + self, + node_id: str, + max_depth: int = 3, + ) -> list[dict]: + """ + Walk upward via parent references. + Useful for "what file/folder does this function belong to?" + """ + ... + + async def get_siblings(self, node_id: str) -> list[dict]: + """Get nodes at the same level (same parent).""" + ... + + async def get_node_with_code(self, node_id: str) -> dict: + """Fetch node + its CodeContentSchema content.""" + ... diff --git a/src/backend/app/agent/context/token_tracker.py b/src/backend/app/agent/context/token_tracker.py new file mode 100644 index 00000000..4a8fb7ec --- /dev/null +++ b/src/backend/app/agent/context/token_tracker.py @@ -0,0 +1,38 @@ +from langchain_core.callbacks import BaseCallbackHandler +from pydantic import BaseModel +from typing import Optional + + +class TokenUsage(BaseModel): + """Accumulated token usage for a single run.""" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + model: Optional[str] = None + + +class TokenTracker(BaseCallbackHandler): + """LangChain callback handler that accumulates token usage across calls.""" + + def __init__(self, max_total_tokens: int = 128_000): + self.max_total_tokens = max_total_tokens + self.usage = TokenUsage() + + def on_llm_end(self, response, **kwargs): + """Called after each LLM invocation — accumulates usage.""" + if hasattr(response, "llm_output") and response.llm_output: + usage = response.llm_output.get("token_usage", {}) + self.usage.prompt_tokens += usage.get("prompt_tokens", 0) + self.usage.completion_tokens += usage.get("completion_tokens", 0) + self.usage.total_tokens += usage.get("total_tokens", 0) + + @property + def remaining(self) -> int: + return self.max_total_tokens - self.usage.total_tokens + + @property + def over_budget(self) -> bool: + return self.usage.total_tokens >= self.max_total_tokens + + def get_usage(self) -> TokenUsage: + return self.usage.model_copy() diff --git a/src/backend/app/agent/context/vectorlink_client.py b/src/backend/app/agent/context/vectorlink_client.py new file mode 100644 index 00000000..1275728c --- /dev/null +++ b/src/backend/app/agent/context/vectorlink_client.py @@ -0,0 +1,60 @@ +import json +import httpx +from typing import Optional + + +class VectorLinkClient: + """Async HTTP client for the VectorLink semantic indexer.""" + + def __init__(self, base_url: str = "http://localhost:8080"): + self.base_url = base_url + self.headers = { + "Content-Type": "application/json", + "VECTORLINK_EMBEDDING_API_KEY": 'openai secreate', + } + self._client = httpx.AsyncClient( + base_url=base_url, headers=self.headers, timeout=30.0) + + async def index_document( + self, + db: str, + commit_id: str, + branch: str = "main", + ) -> str: + """Trigger vectorlink to start indexing and return the task id.""" + + try: + response = await self._client.get( + f"/api/index", + params={"domain": f"admin/{db}", + "commit": commit_id}, + ) + task_id = response.text + return task_id + except httpx.HTTPStatusError as e: + raise RuntimeError(f"Failed to index document: {e}") from e + + async def search( + self, + db: str, + commit_id: str, + query: str, + branch: str = "main", + ) -> list[dict]: + """Search the vectorlink index and return the results.""" + + try: + response = await self._client.post( + f"/api/search", + params={"domain": f"admin/{db}", + "commit": commit_id}, + json={"search": query}, + ) + if response.status_code != 200: + raise RuntimeError(f"Failed to search: {response.text}") + + return json.loads(response.text) + except httpx.HTTPStatusError as e: + raise RuntimeError(f"Failed to search: {e}") from e + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse search response: {e}") from e diff --git a/src/backend/app/agent/tools/tool_registry.py b/src/backend/app/agent/tools/tool_registry.py index 7f6232fb..41704d89 100644 --- a/src/backend/app/agent/tools/tool_registry.py +++ b/src/backend/app/agent/tools/tool_registry.py @@ -1,4 +1,5 @@ from app.agent.tools.base import BaseTool +from app.agent.tools.tool_card import ToolCard class ToolRegistry: diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py new file mode 100644 index 00000000..4749d008 --- /dev/null +++ b/src/backend/app/agent/workflows/description_gen.py @@ -0,0 +1,66 @@ +from agent.workflows.base import BaseWorkflow +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.llm.factory import create_llm +from app.agent.models.task_status import TaskStatus + + +class DescriptionGeneratorWorkflow(BaseWorkflow): + name = "description_generator" + description = "Generate descriptions for code elements recursively" + + def __init__(self, graph: GraphTraversal, llm_factory): + self.graph = graph + self.llm_factory = llm_factory + + async def run( + self, + node_id: str, + direction: str = "down", # "up" | "down" + mode: str = "description", # "description" | "documentation" | "both" + max_depth: int = 5, + task_status: TaskStatus | None = None, + ): + if direction == "down": + nodes = await self.graph.traverse_down(node_id, max_depth) + nodes = self._sort_leaf_first(nodes) + else: + nodes = await self.graph.traverse_up(node_id, max_depth) + + total = len(nodes) + results = {} + + for i, node in enumerate(nodes): + # 2. Gather context + code = await self.graph.get_node_with_code(node["id"]) + child_descriptions = [ + results[cid] for cid in node.get("children", []) + if cid in results + ] + + # 3. Generate + if mode in ("description", "both"): + desc = await self._generate_description(node, code, child_descriptions) + results[node["id"]] = desc + # TODO: persist to TerminusDB + + if mode in ("documentation", "both"): + doc = await self._generate_documentation(node, code, child_descriptions) + # TODO: persist as DocumentSchema + + # 4. Progress + if task_status: + task_status.progress = (i + 1) / total + task_status.progress_message = f"Processed {node['name']}" + + return results + + async def _generate_description(self, node, code, child_descriptions) -> str: + llm = self.llm_factory() + prompt = self._build_description_prompt(node, code, child_descriptions) + return await llm.ainvoke(prompt) + + async def _generate_documentation(self, node, code, child_descriptions) -> str: + llm = self.llm_factory() + prompt = self._build_documentation_prompt( + node, code, child_descriptions) + return await llm.ainvoke(prompt) From 0100282a2570d2bcbcb1955c026a611a7bc6a880 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 22:37:04 +0300 Subject: [PATCH 10/49] conversation state improved --- src/backend/app/agent/models/conversation.py | 56 +++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/backend/app/agent/models/conversation.py b/src/backend/app/agent/models/conversation.py index 90588cd8..7d0a5f1c 100644 --- a/src/backend/app/agent/models/conversation.py +++ b/src/backend/app/agent/models/conversation.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field -from typing import Optional, Literal +from typing import Optional, Literal, Union from datetime import datetime from enum import Enum @@ -31,8 +31,58 @@ class EventPart(BaseModel): payload: dict = {} +class SubTaskState(str, Enum): + PENDING = "pending" # ○ not started + RUNNING = "running" # ● in progress + COMPLETED = "completed" # ✓ done + FAILED = "failed" # ✗ error + SKIPPED = "skipped" # — skipped + + +class SubTask(BaseModel): + """One step in a task's timeline.""" + id: str + name: str # e.g. "parse_imports.py" + description: str = "" + state: SubTaskState = SubTaskState.PENDING + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + # TerminusDB node IDs modified by this step + touched_node_ids: list[str] = [] + + +class TaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TaskPart(BaseModel): + """ + A task embedded inside a conversation message. + Expands into a sub-task timeline with status + touched nodes. + """ + + type: Literal["task"] = "task" + task_id: str + title: str + description: str = "" + state: TaskState = TaskState.PENDING + created_at: datetime = Field(default_factory=datetime.utcnow) + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + progress: float = 0.0 + sub_tasks: list[SubTask] = [] + touched_node_ids: list[str] = [] + workflow_name: Optional[str] = None + workflow_params: Optional[dict] = None + + # Union of all part types -MessagePart = TextPart | ToolCallPart | EventPart +MessagePart = Union[TextPart, ToolCallPart, EventPart, TaskPart] class ConversationMessage(BaseModel): @@ -47,9 +97,11 @@ class ConversationMessage(BaseModel): class ConversationSummary(BaseModel): id: str title: str + description: str = "" # LLM-generated summary created_at: datetime updated_at: datetime message_count: int = 0 + has_active_task: bool = False # quick flag for UI class Conversation(ConversationSummary): From 4c9c183f4a7870ce174224fe2210c6b9676bffc8 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Wed, 18 Mar 2026 23:29:51 +0300 Subject: [PATCH 11/49] more improvement --- src/backend/app/agent/config.py | 19 +++++ src/backend/app/agent/graph/nodes.py | 4 +- src/backend/app/agent/llm/factory.py | 38 ++++++++++ src/backend/app/agent/llm/openai_provider.py | 45 ++++++++++++ src/backend/app/agent/llm/provider.py | 38 ++++++++++ src/backend/app/agent/runner/executor.py | 72 ++++++++++++++----- src/backend/app/agent/runner/task_manager.py | 7 +- .../app/agent/workflows/description_gen.py | 4 +- src/backend/pyproject.toml | 1 + uv.lock | 16 +++++ 10 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 src/backend/app/agent/config.py create mode 100644 src/backend/app/agent/llm/factory.py create mode 100644 src/backend/app/agent/llm/openai_provider.py create mode 100644 src/backend/app/agent/llm/provider.py diff --git a/src/backend/app/agent/config.py b/src/backend/app/agent/config.py new file mode 100644 index 00000000..c92e232a --- /dev/null +++ b/src/backend/app/agent/config.py @@ -0,0 +1,19 @@ +from pydantic_settings import BaseSettings + + +class AgentConfig(BaseSettings): + # LLM defaults + default_provider: str = "openai" + default_model: str = "gpt-4o" + openai_api_key: str = "" + + # Agent behavior + max_iterations: int = 10 + max_total_tokens: int = 128_000 + + # VectorLink + vectorlink_url: str = "http://localhost:8080" + + class Config: + env_prefix = "AGENT_" + env_file = ".env" diff --git a/src/backend/app/agent/graph/nodes.py b/src/backend/app/agent/graph/nodes.py index 2de9cd29..f7642f2b 100644 --- a/src/backend/app/agent/graph/nodes.py +++ b/src/backend/app/agent/graph/nodes.py @@ -1,4 +1,5 @@ from app.agent.graph.state import AgentState +from app.agent.llm import factory async def planner(state: AgentState) -> AgentState: @@ -7,7 +8,8 @@ async def planner(state: AgentState) -> AgentState: Decides: use a tool, answer directly, or give up. Populates: plan, selected_tool, tool_input. """ - ... + llm = factory.create() + response = await llm.invoke(state["messages"]) async def executor(state: AgentState) -> AgentState: diff --git a/src/backend/app/agent/llm/factory.py b/src/backend/app/agent/llm/factory.py new file mode 100644 index 00000000..10129bf1 --- /dev/null +++ b/src/backend/app/agent/llm/factory.py @@ -0,0 +1,38 @@ +from app.agent.llm.provider import LLMProvider +from app.agent.config import AgentConfig + + +class LLMFactory: + """Create LLM provider instances based on configuration.""" + + def __init__(self, config: AgentConfig): + self.config = config + self._providers: dict[str, type[LLMProvider]] = {} + + def register_provider( + self, + name: str, + provider_cls: type[LLMProvider], + + ): + self._providers[name] = provider_cls + + def create( + self, + *, + provider: str | None = None, + model: str | None = None, + **kwargs, + ) -> LLMProvider: + provider = provider or self.config.default_provider # e.g. "openai" + model = model or self.config.default_model # e.g. "gpt-4o" + provider_cls = self._providers[provider] + + return provider_cls(model=model, **kwargs) + + def list_available(self) -> list[dict]: + """List registered providers and their supported models.""" + return [ + {"provider": name, "models": cls.supported_models()} + for name, cls in self._providers.items() + ] diff --git a/src/backend/app/agent/llm/openai_provider.py b/src/backend/app/agent/llm/openai_provider.py new file mode 100644 index 00000000..32db618a --- /dev/null +++ b/src/backend/app/agent/llm/openai_provider.py @@ -0,0 +1,45 @@ +from langchain_openai import ChatOpenAI +from app.agent.llm.provider import LLMProvider +from typing import AsyncIterator +from langchain_core.messages import BaseMessage + + +class OpenAIProvider(LLMProvider): + name = "openai" + + MODEL_CONTEXTS = { + "gpt-4o": 128_000, + "gpt-4o-mini": 128_000, + "gpt-4-turbo": 128_000, + "gpt-3.5-turbo": 16_385, + } + + def __init__(self, model: str = "gpt-4o", **kwargs): + self.model = model + self._llm = ChatOpenAI(model=model, **kwargs) + + async def invoke( + self, + messages: list[BaseMessage], + **kwargs, + ) -> BaseMessage: + return await self._llm.ainvoke(messages, **kwargs) + + async def stream( + self, + messages: list[BaseMessage], + **kwargs, + ): + async for chunk in self._llm.astream(messages, **kwargs): + if chunk.content: + yield chunk.content + + def supports_tools(self) -> bool: + return True + + def max_context_tokens(self) -> int: + return self.MODEL_CONTEXTS.get(self.model, 128_000) + + @classmethod + def supported_models(cls) -> list[str]: + return list(cls.MODEL_CONTEXTS.keys()) diff --git a/src/backend/app/agent/llm/provider.py b/src/backend/app/agent/llm/provider.py new file mode 100644 index 00000000..df082e4e --- /dev/null +++ b/src/backend/app/agent/llm/provider.py @@ -0,0 +1,38 @@ +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator +from langchain_core.messages import BaseMessage + + +class LLMProvider(ABC): + """Abstract interface for an LLM provider.""" + + name: str # e.g. "openai", "anthropic", "local" + model: str # e.g. "gpt-4o", "claude-3-sonnet" + + @abstractmethod + async def invoke( + self, + messages: list[BaseMessage], + **kwargs, + ) -> BaseMessage: + """Single-shot invocation. Returns one complete message.""" + ... + + @abstractmethod + async def stream( + self, + messages: list[BaseMessage], + **kwargs, + ) -> AsyncIterator[str]: + """Streaming invocation. Yields token chunks.""" + ... + + @abstractmethod + def supports_tools(self) -> bool: + """Does this provider support native tool/function calling?""" + ... + + @abstractmethod + def max_context_tokens(self) -> int: + """Maximum context window size for this model.""" + ... diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index fcfb35ef..d199784e 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -3,32 +3,70 @@ from agent.runner.task_manager import TaskManager from agent.graph.builder import build_agent_graph from agent.workflows.base import BaseWorkflow +from app.agent.models.conversation import ConversationMessage, MessageRole, SubTask, TaskPart, TextPart class AgentExecutor: """High-level entry point for running agents and workflows as tasks.""" - def __init__(self, task_manager: TaskManager): + def __init__( + self, + task_manager: TaskManager, + llm_factory, + conversation_store, + ): self.task_manager = task_manager + self.llm_factory = llm_factory + self.store = conversation_store - def run_workflow(self, workflow: BaseWorkflow, **kwargs) -> str: - """Submit a workflow for background execution.""" - return self.task_manager.submit( + async def run_workflow( + self, + workflow: BaseWorkflow, + conversation_id: str | None = None, + **kwargs, + ) -> tuple[str, str]: + """ + Submit a workflow for background execution. + If no conversation_id, creates a new conversation with LLM-generated title. + Returns (conversation_id, task_id). + """ + # 1. Auto-create conversation if standalone + if conversation_id is None: + title, description = await self._generate_title(workflow, kwargs) + conversation_id = self.store.create_conversation( + title, description) + + # 2. Create TaskPart and insert as assistant message + task_part = TaskPart( + task_id=..., + title=f"{workflow.name}: {kwargs.get('node_id', '')}", + workflow_name=workflow.name, + workflow_params=kwargs, + ) + self.store.add_message(conversation_id, ConversationMessage( + id=..., role=MessageRole.ASSISTANT, + parts=[TextPart(text=f"Starting {workflow.name}..."), task_part], + )) + + # 3. Submit to background with progress callback + task_id = self.task_manager.submit( name=f"workflow:{workflow.name}", coro_factory=workflow.run, + on_subtask_update=lambda st: self._update_task_part( + conversation_id, task_part, st), **kwargs, ) + return conversation_id, task_id - def run_agent_chat(self, conversation_id: str, message: str) -> str: - """Submit an agent chat turn for background execution.""" - async def _run_agent(**kw): - graph = build_agent_graph() - result = await graph.ainvoke(kw) - return result - - return self.task_manager.submit( - name=f"agent:chat:{conversation_id}", - coro_factory=_run_agent, - conversation_id=conversation_id, - message=message, - ) + async def _generate_title(self, workflow, params) -> tuple[str, str]: + """Use LLM to generate conversation title + description from the workflow.""" + llm = self.llm_factory.create(model="gpt-4o-mini") + # ... prompt LLM to generate title/description + ... + + def _update_task_part(self, conv_id, task_part, sub_task: SubTask): + """Update TaskPart's sub_tasks list and push via WebSocket.""" + task_part.sub_tasks.append(sub_task) + task_part.touched_node_ids.extend(sub_task.touched_node_ids) + # Push real-time update via WebSocket + ... diff --git a/src/backend/app/agent/runner/task_manager.py b/src/backend/app/agent/runner/task_manager.py index 466b8e7c..9277841f 100644 --- a/src/backend/app/agent/runner/task_manager.py +++ b/src/backend/app/agent/runner/task_manager.py @@ -13,7 +13,12 @@ def __init__(self): self._tasks: dict[str, TaskStatus] = {} self._asyncio_tasks: dict[str, asyncio.Task] = {} - def submit(self, name: str, coro_factory: Callable[..., Any], **kwargs) -> str: + def submit( + self, + name: str, + coro_factory: Callable[..., Any], + **kwargs + ) -> str: task_id = str(uuid.uuid4()) status = TaskStatus( id=task_id, diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index 4749d008..a0d7e9e9 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -55,12 +55,12 @@ async def run( return results async def _generate_description(self, node, code, child_descriptions) -> str: - llm = self.llm_factory() + llm = self.llm_factory.create(model="gpt-4o-mini") prompt = self._build_description_prompt(node, code, child_descriptions) return await llm.ainvoke(prompt) async def _generate_documentation(self, node, code, child_descriptions) -> str: - llm = self.llm_factory() + llm = self.llm_factory.create(model="gpt-4o-mini") prompt = self._build_documentation_prompt( node, code, child_descriptions) return await llm.ainvoke(prompt) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index f1872ca5..8eb83884 100755 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "litellm>=1.82.4", "langgraph>=1.1.2", "langchain>=1.2.12", + "langchain-openai>=1.1.11", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 6be7f7cf..7f9298b0 100755 --- a/uv.lock +++ b/uv.lock @@ -245,6 +245,7 @@ dependencies = [ { name = "jedi" }, { name = "kuzu" }, { name = "langchain" }, + { name = "langchain-openai" }, { name = "langgraph" }, { name = "libcst" }, { name = "litellm" }, @@ -290,6 +291,7 @@ requires-dist = [ { name = "jedi", specifier = ">=0.19.2" }, { name = "kuzu", specifier = ">=0.11.3" }, { name = "langchain", specifier = ">=1.2.12" }, + { name = "langchain-openai", specifier = ">=1.1.11" }, { name = "langgraph", specifier = ">=1.1.2" }, { name = "libcst", specifier = ">=1.7.0" }, { name = "litellm", specifier = ">=1.82.4" }, @@ -1184,6 +1186,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/cb/8704b2a22c0987627ed29464d23a45fb15e10a28fb482f4d84c3bddcbf27/langchain_core-1.2.19-py3-none-any.whl", hash = "sha256:6e74cb0fb443a8046ee298c05c99b67abe54cc57fcbc6d1cd3b0f2485ee47574", size = 503456 }, ] +[[package]] +name = "langchain-openai" +version = "1.1.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/cd/439be2b8deb8bd0d4c470c7c7f66698a84d823e583c3d36a322483cb7cab/langchain_openai-1.1.11.tar.gz", hash = "sha256:44b003a2960d1f6699f23721196b3b97d0c420d2e04444950869213214b7a06a", size = 1088560 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/e4cb42848c25f65969adfb500a06dea1a541831604250fd0d8aa6e54fef5/langchain_openai-1.1.11-py3-none-any.whl", hash = "sha256:a03596221405d38d6852fb865467cb0d9ff9e79f335905eb6a576e8c4874ac71", size = 87694 }, +] + [[package]] name = "langgraph" version = "1.1.2" From f2671b80afa6bf0ea95610b26cb79ef00c2b9a75 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 00:00:23 +0300 Subject: [PATCH 12/49] improvement --- .../app/agent/llm/{ => providers}/openai_provider.py | 0 src/backend/app/api/v1/agent/workflows.py | 6 ++++++ 2 files changed, 6 insertions(+) rename src/backend/app/agent/llm/{ => providers}/openai_provider.py (100%) create mode 100644 src/backend/app/api/v1/agent/workflows.py diff --git a/src/backend/app/agent/llm/openai_provider.py b/src/backend/app/agent/llm/providers/openai_provider.py similarity index 100% rename from src/backend/app/agent/llm/openai_provider.py rename to src/backend/app/agent/llm/providers/openai_provider.py diff --git a/src/backend/app/api/v1/agent/workflows.py b/src/backend/app/api/v1/agent/workflows.py new file mode 100644 index 00000000..7de9302a --- /dev/null +++ b/src/backend/app/api/v1/agent/workflows.py @@ -0,0 +1,6 @@ +def run(): + pass + + +def get_workflows(): + pass From f4b7ab7f8c507e925d3026ef85e5d43eba8fc64d Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 00:30:14 +0300 Subject: [PATCH 13/49] improve --- .../app/agent/workflows/documentation_gen.py | 14 ++++ src/backend/app/api/v1/agent/deps.py | 18 +++++ src/backend/app/api/v1/agent/workflows.py | 77 ++++++++++++++++++- src/backend/app/main.py | 24 ++++++ 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/backend/app/agent/workflows/documentation_gen.py create mode 100644 src/backend/app/api/v1/agent/deps.py diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py new file mode 100644 index 00000000..6346a2e9 --- /dev/null +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -0,0 +1,14 @@ +from app.agent.workflows.base import BaseWorkflow +from app.agent.context.graph_traversal import GraphTraversal + + +class DocumentationGeneratorWorkflow(BaseWorkflow): + name = "documentation_generator" + description = "Generate documentation for code elements recursively" + + def __init__(self, graph: GraphTraversal, llm_factory): + self.graph = graph + self.llm_factory = llm_factory + + async def run(self, node_id: str, direction: str = "down", mode: str = "documentation", max_depth: int = 5): + pass diff --git a/src/backend/app/api/v1/agent/deps.py b/src/backend/app/api/v1/agent/deps.py new file mode 100644 index 00000000..b3fbc636 --- /dev/null +++ b/src/backend/app/api/v1/agent/deps.py @@ -0,0 +1,18 @@ + +from fastapi import Request +from app.agent.runner.executor import AgentExecutor +from app.agent.models.conversation_store import InMemoryConversationStore + + +def get_agent_executor(request: Request) -> AgentExecutor: + """Dependency to get the global AgentExecutor.""" + if not hasattr(request.app.state, "agent_executor"): + raise RuntimeError("Agent executor not initialized in app state.") + return request.app.state.agent_executor + + +def get_conversation_store(request: Request) -> InMemoryConversationStore: + """Dependency to get the global Conversation Store.""" + if not hasattr(request.app.state, "conversation_store"): + raise RuntimeError("Conversation store not initialized in app state.") + return request.app.state.conversation_store diff --git a/src/backend/app/api/v1/agent/workflows.py b/src/backend/app/api/v1/agent/workflows.py index 7de9302a..815f22a0 100644 --- a/src/backend/app/api/v1/agent/workflows.py +++ b/src/backend/app/api/v1/agent/workflows.py @@ -1,6 +1,75 @@ -def run(): - pass +from typing import Optional, Any +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from pydantic import BaseModel -def get_workflows(): - pass +from app.api.v1.agent.deps import get_agent_executor +from app.agent.runner.executor import AgentExecutor +from app.agent.workflows.documentation_gen import DocumentationGeneratorWorkflow +# Import other workflows... + +router = APIRouter(prefix="/workflows", tags=["Agent Workflows"]) + + +# ─── Schemas ────────────────────────────────────────────── + +class RunWorkflowRequest(BaseModel): + workflow_name: str + params: dict[str, Any] + conversation_id: Optional[str] = None + message_id: Optional[str] = None + + +class RunWorkflowResponse(BaseModel): + conversation_id: str + task_id: str + status: str + + +# ─── Routes ─────────────────────────────────────────────── + +@router.post("/run", response_model=RunWorkflowResponse, status_code=202) +async def run_workflow( + req: RunWorkflowRequest, + executor: AgentExecutor = Depends(get_agent_executor), +): + """ + Trigger a background workflow (e.g., documentation generation). + If conversation_id is None, a new conversation is automatically created. + """ + + # 1. Resolve workflow name to class instance + workflow_map = { + "documentation_generator": DocumentationGeneratorWorkflow(), + # "description_generator": DescriptionGeneratorWorkflow(), + } + + workflow = workflow_map.get(req.workflow_name) + if not workflow: + raise HTTPException( + status_code=400, + detail=f"Unknown workflow: {req.workflow_name}" + ) + + try: + # 2. Instruct executor to start the workflow + # The executor handles creating the TaskPart message and submitting to TaskManager + conv_id, task_id = await executor.run_workflow( + workflow=workflow, + conversation_id=req.conversation_id, + **req.params + ) + + # 3. Return accepted status immediately (task is running in background) + return RunWorkflowResponse( + conversation_id=conv_id, + task_id=task_id, + status="accepted_and_running" + ) + + except ValueError as e: + # E.g., invalid params or conversation_id doesn't exist + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to start workflow: {e}") diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 036bf9e2..16a4c32c 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -5,6 +5,10 @@ from app.core.socket.manager import get_socket_manager from app.agent.runner.task_manager import TaskManager +from backend.app.agent.llm.factory import LLMFactory +from backend.app.agent.config import settings +from app.agent.llm.providers.openai_provider import OpenAIProvider +from backend.app.agent.runner.executor import AgentExecutor from .api import root from .db.client import get_terminus_client, close_db_client @@ -30,8 +34,28 @@ async def lifespan(app: FastAPI): watcher_service.set_event_loop( asyncio.get_running_loop() ) + + # 2. Initialize LLM Factory + llm_factory = LLMFactory(settings) + if settings.openai_api_key: + llm_factory.register_provider( + "openai", OpenAIProvider(api_key=settings.openai_api_key)) task_manager = TaskManager() +# 3. Initialize Conversation Store (Singleton for Phase 1 in-memory, or Repo for DB) + + # conversation_store = InMemoryConversationStore() + # 4. Create the Executor (wires runner, LLM, and store together) + executor = AgentExecutor( + task_manager=task_manager, + llm_factory=llm_factory, + conversation_store=conversation_store + ) + + # Attach to app state for dependency injection to use later + app.state.agent_executor = executor + app.state.task_manager = task_manager + app.state.conversation_store = conversation_store app.state.watcher_service = watcher_service app.state.task_manager = task_manager From 1d61726c49fbe882e873d212ca93718a7f685c8c Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 16:04:18 +0300 Subject: [PATCH 14/49] improved --- .../app/agent/context/graph_traversal.py | 162 +++++++++++-- .../agent/llm/providers/openai_provider.py | 2 +- src/backend/app/agent/runner/executor.py | 76 +++++- .../app/agent/workflows/description_gen.py | 220 ++++++++++++++---- .../app/agent/workflows/documentation_gen.py | 188 ++++++++++++++- src/backend/app/api/v1/agent/workflows.py | 15 +- src/backend/app/core/builder/tree_builder.py | 16 +- 7 files changed, 600 insertions(+), 79 deletions(-) diff --git a/src/backend/app/agent/context/graph_traversal.py b/src/backend/app/agent/context/graph_traversal.py index f3717447..96d9e224 100644 --- a/src/backend/app/agent/context/graph_traversal.py +++ b/src/backend/app/agent/context/graph_traversal.py @@ -1,41 +1,175 @@ from app.db.context import ProjectUoW -from app.core.model.nodes import BaseNode +from app.db.async_terminus_client import WOQLQuery as WQ +from app.core.builder.tree_builder import TreeBuilder class GraphTraversal: - """Walk the TerminusDB graph up or down from a starting node.""" + """Walk and shape project graph data for workflow execution.""" + + EDGE_FIELDS = ( + "folder_children", + "file_children", + "class_children", + "function_children", + "call_children", + "code_element_group", + "call_group", + "structure_group", + ) + EDGE_PATTERN = "(" + "|".join(EDGE_FIELDS) + ")" def __init__(self, uow: ProjectUoW): self.repos = uow.get_project_repos() + def _extract_children(self, doc: dict) -> list[str]: + children: list[str] = [] + for edge in self.EDGE_FIELDS: + raw = doc.get(edge) + if raw is None: + continue + if isinstance(raw, (list, set, tuple)): + children.extend([str(item) for item in raw if item]) + else: + children.append(str(raw)) + return list(set(children)) + + @staticmethod + def _normalize_type_name(type_name: str | None) -> str: + if not type_name: + return "" + return type_name.replace("Schema", "") + + def _normalize_doc(self, doc: dict) -> dict: + normalized = dict(doc) + normalized["id"] = normalized.get("@id") + normalized["type"] = normalized.get("@type") + normalized["children"] = self._extract_children(doc) + return normalized + + def _dedupe_nodes(self, nodes: list[dict]) -> list[dict]: + unique: dict[str, dict] = {} + for node in nodes: + node_id = node.get("id") or node.get("@id") + if not node_id: + continue + unique[node_id] = node + return list(unique.values()) + async def traverse_down( self, node_id: str, - max_depth: int = 3, + max_depth: int = 5, node_types: list[str] | None = None, ) -> list[dict]: """ - BFS/DFS downward from node_id. - Returns a flat list of node dicts with depth metadata. - Respects node_types filter (e.g. ["FunctionSchema", "ClassSchema"]). + Get all descendants from node_id and include the start node. + Returns full node docs with normalized `id`, `type`, and `children`. """ - ... + pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}" + query = ( + WQ() + .eq("v:start", node_id) + .path("v:start", f"{self.EDGE_PATTERN}{pattern}", "v:child") + .read_document("v:child", "v:child_doc") + ) + + allowed_types = None + if node_types: + allowed_types = { + self._normalize_type_name(node_type) for node_type in node_types + } + + nodes: list[dict] = [] + if self.repos.client: + result = await self.repos.client.query(query) + for row in result.get("bindings", []): + doc = row.get("child_doc", {}) + if allowed_types: + doc_type = self._normalize_type_name(doc.get("@type")) + if doc_type not in allowed_types: + continue + nodes.append(self._normalize_doc(doc)) + + start_result = await self.repos.client.get_document(node_id) + if start_result: + nodes.append(self._normalize_doc(start_result)) + + return self._dedupe_nodes(nodes) async def traverse_up( self, node_id: str, - max_depth: int = 3, + max_depth: int = 5, ) -> list[dict]: """ - Walk upward via parent references. - Useful for "what file/folder does this function belong to?" + Get all ancestors from node_id and include the start node. + Returns full node docs with normalized `id`, `type`, and `children`. """ - ... + pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}" + query = ( + WQ() + .eq("v:start", node_id) + .path("v:start", f"<{self.EDGE_PATTERN}{pattern}", "v:parent") + .read_document("v:parent", "v:parent_doc") + ) + + nodes: list[dict] = [] + if self.repos.client: + result = await self.repos.client.query(query) + for row in result.get("bindings", []): + doc = row.get("parent_doc", {}) + nodes.append(self._normalize_doc(doc)) + + start_result = await self.repos.client.get_document(node_id) + if start_result: + nodes.append(self._normalize_doc(start_result)) + + return self._dedupe_nodes(nodes) + + async def build_tree(self, node_id: str, max_depth: int = 5): + """Build nested tree nodes for subtree rooted at `node_id`.""" + nodes = await self.traverse_down(node_id=node_id, max_depth=max_depth) + tree = TreeBuilder(base_nodes=nodes).build() + return tree async def get_siblings(self, node_id: str) -> list[dict]: """Get nodes at the same level (same parent).""" - ... + parents = await self.traverse_up(node_id, max_depth=1) + if not parents: + return [] + + parent_id = parents[0]["id"] if parents[0]["id"] != node_id else ( + parents[1]["id"] if len(parents) > 1 else None + ) + if not parent_id: + return [] + + children = await self.traverse_down(parent_id, max_depth=1) + return [c for c in children if c["id"] not in {node_id, parent_id}] async def get_node_with_code(self, node_id: str) -> dict: - """Fetch node + its CodeContentSchema content.""" - ... + """Fetch node and hydrate file code content when linked.""" + if not self.repos.client: + return {} + + doc = await self.repos.client.get_document(node_id) + if not doc: + return {} + + code_ref = doc.get("code_content") + if not code_ref: + return doc + + if isinstance(code_ref, dict): + doc["code_content_data"] = code_ref.get("content", "") + return doc + + if isinstance(code_ref, str): + try: + code_doc = await self.repos.client.get_document(code_ref) + if code_doc: + doc["code_content_data"] = code_doc.get("content", "") + except Exception: + doc["code_content_data"] = "" + + return doc diff --git a/src/backend/app/agent/llm/providers/openai_provider.py b/src/backend/app/agent/llm/providers/openai_provider.py index 32db618a..e26e1ea5 100644 --- a/src/backend/app/agent/llm/providers/openai_provider.py +++ b/src/backend/app/agent/llm/providers/openai_provider.py @@ -4,7 +4,7 @@ from langchain_core.messages import BaseMessage -class OpenAIProvider(LLMProvider): +class OpenAIProvider(LLMProvider, ): name = "openai" MODEL_CONTEXTS = { diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index d199784e..7ee2c9d4 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -4,6 +4,23 @@ from agent.graph.builder import build_agent_graph from agent.workflows.base import BaseWorkflow from app.agent.models.conversation import ConversationMessage, MessageRole, SubTask, TaskPart, TextPart +from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel, Field + + +class ConversationTitleOutput(BaseModel): + """Structured output payload for conversation metadata.""" + + title: str = Field( + description="Short conversation title (3-8 words).", + min_length=3, + max_length=80, + ) + description: str = Field( + description="One sentence summary of the workflow execution goal.", + min_length=8, + max_length=220, + ) class AgentExecutor: @@ -60,9 +77,62 @@ async def run_workflow( async def _generate_title(self, workflow, params) -> tuple[str, str]: """Use LLM to generate conversation title + description from the workflow.""" - llm = self.llm_factory.create(model="gpt-4o-mini") - # ... prompt LLM to generate title/description - ... + workflow_name = getattr(workflow, "name", "workflow") + safe_workflow_name = workflow_name.replace("_", " ").strip().title() + fallback_title = f"{safe_workflow_name} Run" + fallback_description = f"Run `{workflow_name}` with the provided parameters." + + # Keep prompt payload compact and deterministic. + if not params: + params_preview = "None" + else: + preview_items = [] + for key, value in list(params.items())[:8]: + value_str = repr(value) + if len(value_str) > 80: + value_str = f"{value_str[:77]}..." + preview_items.append(f"{key}={value_str}") + params_preview = ", ".join(preview_items) + + messages = [ + SystemMessage( + content=( + "You generate concise conversation metadata for backend workflow runs. " + "Return neutral, technical text without markdown or quotes." + ) + ), + HumanMessage( + content=( + f"Workflow name: {workflow_name}\n" + f"Workflow description: {getattr(workflow, 'description', '')}\n" + f"Parameters: {params_preview}\n\n" + "Generate:\n" + "1) title: short and specific\n" + "2) description: one sentence, action-oriented" + ) + ), + ] + + try: + provider = self.llm_factory.create(model="gpt-4o-mini") + base_llm = getattr(provider, "_llm", None) + if base_llm is None: + return fallback_title, fallback_description + + structured_llm = base_llm.with_structured_output( + ConversationTitleOutput) + result = await structured_llm.ainvoke(messages) + + title = result.title.strip().strip("\"'") + description = result.description.strip().strip("\"'") + if not title: + title = fallback_title + if not description: + description = fallback_description + return title, description + except Exception: + # Never block workflow scheduling on title generation issues. + return fallback_title, fallback_description def _update_task_part(self, conv_id, task_part, sub_task: SubTask): """Update TaskPart's sub_tasks list and push via WebSocket.""" diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index a0d7e9e9..c30e0a32 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -1,66 +1,196 @@ -from agent.workflows.base import BaseWorkflow +from collections import deque +from typing import Any + +from langchain_core.messages import HumanMessage + +from app.agent.workflows.base import BaseWorkflow from app.agent.context.graph_traversal import GraphTraversal -from app.agent.llm.factory import create_llm from app.agent.models.task_status import TaskStatus class DescriptionGeneratorWorkflow(BaseWorkflow): name = "description_generator" - description = "Generate descriptions for code elements recursively" + description = "Generate descriptions recursively from a tree" - def __init__(self, graph: GraphTraversal, llm_factory): + def __init__(self, graph: GraphTraversal | None = None, llm_factory=None): self.graph = graph self.llm_factory = llm_factory async def run( self, node_id: str, - direction: str = "down", # "up" | "down" - mode: str = "description", # "description" | "documentation" | "both" + direction: str = "down", # "up" (leaf -> parent) | "down" (parent -> leaf) max_depth: int = 5, task_status: TaskStatus | None = None, + **kwargs, ): - if direction == "down": - nodes = await self.graph.traverse_down(node_id, max_depth) - nodes = self._sort_leaf_first(nodes) - else: - nodes = await self.graph.traverse_up(node_id, max_depth) - - total = len(nodes) - results = {} - - for i, node in enumerate(nodes): - # 2. Gather context - code = await self.graph.get_node_with_code(node["id"]) - child_descriptions = [ - results[cid] for cid in node.get("children", []) - if cid in results - ] - - # 3. Generate - if mode in ("description", "both"): - desc = await self._generate_description(node, code, child_descriptions) - results[node["id"]] = desc - # TODO: persist to TerminusDB - - if mode in ("documentation", "both"): - doc = await self._generate_documentation(node, code, child_descriptions) - # TODO: persist as DocumentSchema - - # 4. Progress + if self.graph is None: + raise ValueError("GraphTraversal is required for description workflow.") + if self.llm_factory is None: + raise ValueError("LLM factory is required for description workflow.") + if direction not in {"up", "down"}: + raise ValueError(f"Invalid direction: {direction}") + + roots = await self.graph.build_tree(node_id=node_id, max_depth=max_depth) + execution_nodes = self._ordered_nodes(roots=roots, direction=direction) + + total = len(execution_nodes) + if total == 0: + return {"processed": 0, "results": {}} + + generated_descriptions: dict[str, str] = {} + node_updates: dict[str, dict] = {} + + for index, tree_node in enumerate(execution_nodes): + node_id = getattr(tree_node, "id", None) + if not node_id: + continue + + node_doc = await self.graph.get_node_with_code(node_id) + if not node_doc: + continue + + child_descriptions = self._child_values( + tree_node=tree_node, + generated_values=generated_descriptions, + ) + prompt = self._build_description_prompt( + node_doc=node_doc, + child_descriptions=child_descriptions, + ) + generated_description = await self._invoke_llm(prompt) + generated_descriptions[node_id] = generated_description + + updated_node_doc = dict(node_updates.get(node_id, node_doc)) + updated_node_doc["description"] = generated_description + node_updates[node_id] = updated_node_doc + if task_status: - task_status.progress = (i + 1) / total - task_status.progress_message = f"Processed {node['name']}" + task_status.progress = (index + 1) / total + task_status.progress_message = ( + f"Generated description: {node_doc.get('name', node_id)}" + ) - return results + await self._flush_node_updates( + node_updates=node_updates + ) - async def _generate_description(self, node, code, child_descriptions) -> str: - llm = self.llm_factory.create(model="gpt-4o-mini") - prompt = self._build_description_prompt(node, code, child_descriptions) - return await llm.ainvoke(prompt) + return { + "processed": len(generated_descriptions), + "direction": direction, + "description_results": generated_descriptions, + } + + def _ordered_nodes(self, roots: list[Any], direction: str) -> list[Any]: + levels = self._collect_levels(roots) + if direction == "up": + levels = list(reversed(levels)) + return [node for level in levels for node in level] + + def _collect_levels(self, roots: list[Any]) -> list[list[Any]]: + if not roots: + return [] + + levels: list[list[Any]] = [] + queue: deque[tuple[Any, int]] = deque() + visited: set[str] = set() - async def _generate_documentation(self, node, code, child_descriptions) -> str: + for root in roots: + root_id = getattr(root, "id", None) + if not root_id or root_id in visited: + continue + visited.add(root_id) + queue.append((root, 0)) + + while queue: + node, depth = queue.popleft() + while len(levels) <= depth: + levels.append([]) + levels[depth].append(node) + + for child in getattr(node, "children", []) or []: + child_id = getattr(child, "id", None) + if not child_id or child_id in visited: + continue + visited.add(child_id) + queue.append((child, depth + 1)) + + return levels + + def _child_values( + self, + tree_node: Any, + generated_values: dict[str, str], + ) -> list[str]: + values: list[str] = [] + for child in getattr(tree_node, "children", []) or []: + child_id = getattr(child, "id", None) + if not child_id: + continue + child_value = generated_values.get(child_id) + if child_value: + values.append(child_value) + return values + + async def _invoke_llm(self, prompt: str) -> str: llm = self.llm_factory.create(model="gpt-4o-mini") - prompt = self._build_documentation_prompt( - node, code, child_descriptions) - return await llm.ainvoke(prompt) + response = await llm.invoke([HumanMessage(content=prompt)]) + content = getattr(response, "content", "") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if text: + parts.append(text) + return "\n".join(parts).strip() + return str(content).strip() + + def _extract_code_context(self, node_doc: dict) -> str: + node_type = (node_doc.get("@type") or "").replace("Schema", "") + if node_type not in {"File", "Function", "Class"}: + return "" + code_content = node_doc.get("code_content_data") + if isinstance(code_content, str) and code_content.strip(): + return code_content + return "" + + def _build_description_prompt( + self, + *, + node_doc: dict, + child_descriptions: list[str], + ) -> str: + child_context = ( + "\n".join([f"- {item}" for item in child_descriptions]) + if child_descriptions else "None" + ) + code_context = self._extract_code_context(node_doc) or "No direct code content found." + + return ( + "Task: description\n" + "Write a concise technical description of this node.\n" + "Use child descriptions for context and avoid repetition.\n\n" + f"Node id: {node_doc.get('@id')}\n" + f"Node type: {node_doc.get('@type')}\n" + f"Node name: {node_doc.get('name')}\n" + f"Current description: {node_doc.get('description', '')}\n\n" + f"Code context:\n{code_context}\n\n" + f"Child descriptions:\n{child_context}\n" + ) + + async def _flush_node_updates( + self, + *, + node_updates: dict[str, dict], + ) -> None: + if not self.graph or not self.graph.repos.client: + return + + if node_updates: + await self.graph.repos.client.update_document( + list(node_updates.values()), + commit_msg=f"Workflow: update {len(node_updates)} node descriptions", + ) diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py index 6346a2e9..88afe914 100644 --- a/src/backend/app/agent/workflows/documentation_gen.py +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -1,14 +1,186 @@ -from app.agent.workflows.base import BaseWorkflow from app.agent.context.graph_traversal import GraphTraversal +from app.agent.workflows.description_gen import DescriptionGeneratorWorkflow +from app.agent.models.task_status import TaskStatus +from app.core.model.schemas import DocumentSchema +from datetime import datetime, timezone -class DocumentationGeneratorWorkflow(BaseWorkflow): +class DocumentationGeneratorWorkflow(DescriptionGeneratorWorkflow): name = "documentation_generator" - description = "Generate documentation for code elements recursively" + description = "Generate documentation recursively from a tree" - def __init__(self, graph: GraphTraversal, llm_factory): - self.graph = graph - self.llm_factory = llm_factory + def __init__(self, graph: GraphTraversal | None = None, llm_factory=None): + super().__init__(graph=graph, llm_factory=llm_factory) - async def run(self, node_id: str, direction: str = "down", mode: str = "documentation", max_depth: int = 5): - pass + async def run( + self, + node_id: str, + direction: str = "down", + max_depth: int = 5, + task_status: TaskStatus | None = None, + **kwargs, + ): + # Documentation starts only after description phase finishes. + if task_status: + task_status.progress = 0.0 + task_status.progress_message = "Generating descriptions before documentation..." + + description_result = await super().run( + node_id=node_id, + direction=direction, + max_depth=max_depth, + task_status=None, + ) + + if self.graph is None: + raise ValueError("GraphTraversal is required for documentation workflow.") + + roots = await self.graph.build_tree(node_id=node_id, max_depth=max_depth) + execution_nodes = self._ordered_nodes(roots=roots, direction=direction) + if not execution_nodes: + return {"processed": 0, "documentation_results": {}, "upserted_document_ids": []} + + description_values: dict[str, str] = description_result.get("description_results", {}) + documentation_values: dict[str, str] = {} + + total = len(execution_nodes) + for index, tree_node in enumerate(execution_nodes): + current_node_id = getattr(tree_node, "id", None) + if not current_node_id: + continue + + node_doc = await self.graph.get_node_with_code(current_node_id) + if not node_doc: + continue + + child_documentations = self._child_values( + tree_node=tree_node, + generated_values=documentation_values, + ) + child_descriptions = self._child_values( + tree_node=tree_node, + generated_values=description_values, + ) + + prompt = self._build_documentation_prompt( + node_doc=node_doc, + node_description=description_values.get(current_node_id, node_doc.get("description", "")), + child_documentations=child_documentations, + child_descriptions=child_descriptions, + ) + documentation_values[current_node_id] = await self._invoke_llm(prompt) + + if task_status: + phase_progress = (index + 1) / total + task_status.progress = 0.5 + (phase_progress * 0.5) + task_status.progress_message = ( + f"Generated documentation: {node_doc.get('name', current_node_id)}" + ) + + upserted_doc_ids = await self._flush_documentation_batch(documentation_values) + return { + "processed": len(documentation_values), + "direction": direction, + "description_results": description_values, + "documentation_results": documentation_values, + "upserted_document_ids": upserted_doc_ids, + } + + def _build_documentation_prompt( + self, + *, + node_doc: dict, + node_description: str, + child_documentations: list[str], + child_descriptions: list[str], + ) -> str: + child_doc_context = ( + "\n".join([f"- {item}" for item in child_documentations]) + if child_documentations else "None" + ) + child_desc_context = ( + "\n".join([f"- {item}" for item in child_descriptions]) + if child_descriptions else "None" + ) + code_context = self._extract_code_context(node_doc) or "No direct code content found." + + return ( + "Task: documentation\n" + "Write practical technical documentation for this node.\n" + "Use node description and child outputs to keep hierarchy-consistent docs.\n\n" + f"Node id: {node_doc.get('@id')}\n" + f"Node type: {node_doc.get('@type')}\n" + f"Node name: {node_doc.get('name')}\n" + f"Node description: {node_description}\n\n" + f"Code context:\n{code_context}\n\n" + f"Child documentations:\n{child_doc_context}\n\n" + f"Child descriptions:\n{child_desc_context}\n" + ) + + @staticmethod + def _documentation_doc_id(node_id: str) -> str: + safe = node_id.replace("/", "_").replace(":", "_") + return f"DocumentSchema/{safe}_workflow_documentation" + + async def _flush_documentation_batch(self, documentation_values: dict[str, str]) -> list[str]: + if not self.graph or not self.graph.repos.client or not documentation_values: + return [] + + client = self.graph.repos.client + now = datetime.now(timezone.utc) + doc_ids = [self._documentation_doc_id(node_id) for node_id in documentation_values] + + existing_docs: dict[str, dict] = {} + try: + existing = await client.get_documents(doc_ids) + existing_docs = {doc.get("@id"): doc for doc in existing} + except Exception: + existing_docs = {} + + documents_to_upsert: list[DocumentSchema] = [] + node_updates: list[dict] = [] + + for node_id, content in documentation_values.items(): + doc_id = self._documentation_doc_id(node_id) + existing_doc = existing_docs.get(doc_id, {}) + created_at = existing_doc.get("created_at", now) + + documents_to_upsert.append( + DocumentSchema( + _id=doc_id, + name=f"workflow_doc:{node_id}", + description="Generated by documentation workflow.", + data=content, + created_at=created_at, + updated_at=now, + ) + ) + + node_doc = await self.graph.get_node_with_code(node_id) + if not node_doc: + continue + + current_docs = node_doc.get("documents") + if isinstance(current_docs, set): + docs_set = set(current_docs) + elif isinstance(current_docs, list): + docs_set = set(current_docs) + elif current_docs: + docs_set = {str(current_docs)} + else: + docs_set = set() + docs_set.add(doc_id) + node_doc["documents"] = docs_set + node_updates.append(node_doc) + + await client.update_document( + documents_to_upsert, + commit_msg=f"Workflow: upsert {len(documents_to_upsert)} generated documents", + ) + if node_updates: + await client.update_document( + node_updates, + commit_msg=f"Workflow: update {len(node_updates)} node document links", + ) + + return doc_ids diff --git a/src/backend/app/api/v1/agent/workflows.py b/src/backend/app/api/v1/agent/workflows.py index 815f22a0..95db5200 100644 --- a/src/backend/app/api/v1/agent/workflows.py +++ b/src/backend/app/api/v1/agent/workflows.py @@ -1,12 +1,13 @@ from typing import Optional, Any -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.api.v1.agent.deps import get_agent_executor from app.agent.runner.executor import AgentExecutor from app.agent.workflows.documentation_gen import DocumentationGeneratorWorkflow -# Import other workflows... +from app.agent.workflows.description_gen import DescriptionGeneratorWorkflow + router = APIRouter(prefix="/workflows", tags=["Agent Workflows"]) @@ -38,10 +39,10 @@ async def run_workflow( If conversation_id is None, a new conversation is automatically created. """ - # 1. Resolve workflow name to class instance + # 1. Resolve workflow name to class instance (route decides description vs documentation) workflow_map = { "documentation_generator": DocumentationGeneratorWorkflow(), - # "description_generator": DescriptionGeneratorWorkflow(), + "description_generator": DescriptionGeneratorWorkflow(), } workflow = workflow_map.get(req.workflow_name) @@ -52,12 +53,16 @@ async def run_workflow( ) try: + params = dict(req.params or {}) + # Keep generations separate by route; ignore legacy combined mode params. + params.pop("mode", None) + # 2. Instruct executor to start the workflow # The executor handles creating the TaskPart message and submitting to TaskManager conv_id, task_id = await executor.run_workflow( workflow=workflow, conversation_id=req.conversation_id, - **req.params + **params ) # 3. Return accepted status immediately (task is running in background) diff --git a/src/backend/app/core/builder/tree_builder.py b/src/backend/app/core/builder/tree_builder.py index 7c17f5ff..928662c2 100644 --- a/src/backend/app/core/builder/tree_builder.py +++ b/src/backend/app/core/builder/tree_builder.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional, Set from pydantic import BaseModel @@ -13,7 +14,10 @@ ProjectTreeNode, ) -# Schema @type or Node class -> tree model (nodes have children as string IDs; tree nodes have nested objects) +logger = logging.getLogger(__name__) + +# Schema @type or Node class -> tree model. +# Nodes use children as string IDs; tree nodes use nested objects. SCHEMA_TO_TREE = { "ProjectSchema": ProjectTreeNode, "FolderSchema": FolderTreeNode, @@ -220,7 +224,10 @@ def _inject_added_nodes( return list(merged.values()) def _propagate_statuses(self, nodes_map: Dict[str, AnyTreeNode]) -> None: - """Bubble up changes: if child is added/removed/modified/moved, parent becomes modified.""" + """ + Bubble up changes: + if child is added/removed/modified/moved, parent becomes modified. + """ changed_ids = { nid for nid, status in self.status_map.items() if status in ("added", "removed", "modified", "moved") @@ -282,7 +289,10 @@ def build(self) -> List[AnyTreeNode]: return result - def _build_tree_from_dicts(self, node_dicts: List[Dict[str, Any]]) -> List[AnyTreeNode]: + def _build_tree_from_dicts( + self, + node_dicts: List[Dict[str, Any]], + ) -> List[AnyTreeNode]: """Build tree from prepared node dictionaries.""" if not node_dicts: return [] From 78f3dddcc903560c71d31a777426b24a41a32b07 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 16:31:16 +0300 Subject: [PATCH 15/49] fix --- src/backend/app/agent/config.py | 32 +- .../app/agent/models/conversation_store.py | 305 ++++++++++++++++++ src/backend/app/agent/runner/executor.py | 39 +-- src/backend/app/api/v1/agent/deps.py | 4 +- src/backend/app/config/settings.py | 7 +- src/backend/app/main.py | 27 +- 6 files changed, 376 insertions(+), 38 deletions(-) create mode 100644 src/backend/app/agent/models/conversation_store.py diff --git a/src/backend/app/agent/config.py b/src/backend/app/agent/config.py index c92e232a..5eedc7bc 100644 --- a/src/backend/app/agent/config.py +++ b/src/backend/app/agent/config.py @@ -1,4 +1,9 @@ -from pydantic_settings import BaseSettings +import os +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict class AgentConfig(BaseSettings): @@ -8,12 +13,27 @@ class AgentConfig(BaseSettings): openai_api_key: str = "" # Agent behavior - max_iterations: int = 10 - max_total_tokens: int = 128_000 + max_iterations: int = Field(default=10, ge=1, le=1000) + max_total_tokens: int = Field(default=128_000, ge=1024) # VectorLink vectorlink_url: str = "http://localhost:8080" - class Config: - env_prefix = "AGENT_" - env_file = ".env" + # Conversation store + conversation_store_backend: Literal["memory", "sqlite"] = "memory" + conversation_store_sqlite_path: str = "data/agent_conversations.sqlite3" + + model_config = SettingsConfigDict( + env_prefix="AGENT_", + env_file=os.environ.get("ENV_FILE", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + +@lru_cache() +def get_agent_settings() -> AgentConfig: + return AgentConfig() + + +settings = get_agent_settings() diff --git a/src/backend/app/agent/models/conversation_store.py b/src/backend/app/agent/models/conversation_store.py new file mode 100644 index 00000000..a4f1ebb6 --- /dev/null +++ b/src/backend/app/agent/models/conversation_store.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import sqlite3 +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Protocol + +from app.agent.models.conversation import ( + Conversation, + ConversationMessage, + ConversationSummary, +) + + +class ConversationStore(Protocol): + def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + ... + + def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> None: + ... + + def get_conversation(self, conversation_id: str) -> Conversation | None: + ... + + def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: + ... + + +class InMemoryConversationStore: + """Simple process-local conversation store.""" + + def __init__(self): + self._lock = threading.Lock() + self._conversations: dict[str, Conversation] = {} + + def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + conversation_id = str(uuid.uuid4()) + now = datetime.utcnow() + conversation = Conversation( + id=conversation_id, + title=title, + description=description, + created_at=now, + updated_at=now, + messages=[], + metadata=metadata or {}, + ) + with self._lock: + self._conversations[conversation_id] = conversation + return conversation_id + + def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> None: + with self._lock: + conversation = self._conversations.get(conversation_id) + if conversation is None: + raise ValueError(f"Conversation not found: {conversation_id}") + conversation.messages.append(message) + conversation.updated_at = datetime.utcnow() + conversation.message_count = len(conversation.messages) + + def get_conversation(self, conversation_id: str) -> Conversation | None: + with self._lock: + conversation = self._conversations.get(conversation_id) + if conversation is None: + return None + return Conversation.model_validate(conversation.model_dump()) + + def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: + with self._lock: + conversations = sorted( + self._conversations.values(), + key=lambda item: item.updated_at, + reverse=True, + ) + sliced = conversations[: max(1, limit)] + return [ + ConversationSummary( + id=item.id, + title=item.title, + description=item.description, + created_at=item.created_at, + updated_at=item.updated_at, + message_count=len(item.messages), + ) + for item in sliced + ] + + +class SQLiteConversationStore: + """SQLite-backed conversation store with the same interface.""" + + def __init__(self, db_path: str): + self.db_path = db_path + self._lock = threading.Lock() + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _init_schema(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS conversation_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(conversation_id) REFERENCES conversations(id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_messages_conversation + ON conversation_messages(conversation_id, created_at) + """ + ) + conn.commit() + + def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + conversation_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + metadata_json = "{}" + if metadata: + from json import dumps + + metadata_json = dumps(metadata) + + with self._lock: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO conversations ( + id, title, description, metadata_json, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + conversation_id, + title, + description, + metadata_json, + now, + now, + ), + ) + conn.commit() + return conversation_id + + def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> None: + with self._lock: + with self._connect() as conn: + row = conn.execute( + "SELECT id FROM conversations WHERE id = ?", + (conversation_id,), + ).fetchone() + if row is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + message_json = message.model_dump_json() + created_at = datetime.utcnow().isoformat() + conn.execute( + """ + INSERT INTO conversation_messages ( + id, conversation_id, message_json, created_at + ) + VALUES (?, ?, ?, ?) + """, + (message.id, conversation_id, message_json, created_at), + ) + conn.execute( + "UPDATE conversations SET updated_at = ? WHERE id = ?", + (created_at, conversation_id), + ) + conn.commit() + + def get_conversation( + self, + conversation_id: str, + ) -> Conversation | None: + from json import loads + + with self._lock: + with self._connect() as conn: + row = conn.execute( + """ + SELECT + id, title, description, metadata_json, created_at, updated_at + FROM conversations + WHERE id = ? + """, + (conversation_id,), + ).fetchone() + if row is None: + return None + + message_rows = conn.execute( + """ + SELECT message_json + FROM conversation_messages + WHERE conversation_id = ? + ORDER BY created_at ASC + """, + (conversation_id,), + ).fetchall() + + messages = [ + ConversationMessage.model_validate_json(item["message_json"]) + for item in message_rows + ] + metadata = loads(row["metadata_json"]) if row["metadata_json"] else {} + created_at = datetime.fromisoformat(row["created_at"]) + updated_at = datetime.fromisoformat(row["updated_at"]) + + return Conversation( + id=row["id"], + title=row["title"], + description=row["description"], + created_at=created_at, + updated_at=updated_at, + message_count=len(messages), + messages=messages, + metadata=metadata, + ) + + def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: + with self._lock: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT + c.id, + c.title, + c.description, + c.created_at, + c.updated_at, + ( + SELECT COUNT(1) + FROM conversation_messages m + WHERE m.conversation_id = c.id + ) AS message_count + FROM conversations c + ORDER BY c.updated_at DESC + LIMIT ? + """, + (max(1, limit),), + ).fetchall() + + return [ + ConversationSummary( + id=row["id"], + title=row["title"], + description=row["description"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + message_count=row["message_count"], + ) + for row in rows + ] diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index 7ee2c9d4..ebe1dfe3 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -1,9 +1,11 @@ # agent/runner/executor.py -from agent.runner.task_manager import TaskManager -from agent.graph.builder import build_agent_graph -from agent.workflows.base import BaseWorkflow +import uuid + +from app.agent.runner.task_manager import TaskManager +from app.agent.workflows.base import BaseWorkflow from app.agent.models.conversation import ConversationMessage, MessageRole, SubTask, TaskPart, TextPart +from app.agent.models.conversation_store import ConversationStore from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field @@ -30,7 +32,7 @@ def __init__( self, task_manager: TaskManager, llm_factory, - conversation_store, + conversation_store: ConversationStore, ): self.task_manager = task_manager self.llm_factory = llm_factory @@ -53,25 +55,26 @@ async def run_workflow( conversation_id = self.store.create_conversation( title, description) - # 2. Create TaskPart and insert as assistant message + # 2. Submit task and attach a timeline message to the conversation + task_id = self.task_manager.submit( + name=f"workflow:{workflow.name}", + coro_factory=workflow.run, + **kwargs, + ) + task_part = TaskPart( - task_id=..., + task_id=task_id, title=f"{workflow.name}: {kwargs.get('node_id', '')}", workflow_name=workflow.name, workflow_params=kwargs, ) - self.store.add_message(conversation_id, ConversationMessage( - id=..., role=MessageRole.ASSISTANT, - parts=[TextPart(text=f"Starting {workflow.name}..."), task_part], - )) - - # 3. Submit to background with progress callback - task_id = self.task_manager.submit( - name=f"workflow:{workflow.name}", - coro_factory=workflow.run, - on_subtask_update=lambda st: self._update_task_part( - conversation_id, task_part, st), - **kwargs, + self.store.add_message( + conversation_id, + ConversationMessage( + id=str(uuid.uuid4()), + role=MessageRole.ASSISTANT, + parts=[TextPart(text=f"Starting {workflow.name}..."), task_part], + ), ) return conversation_id, task_id diff --git a/src/backend/app/api/v1/agent/deps.py b/src/backend/app/api/v1/agent/deps.py index b3fbc636..0094ab9d 100644 --- a/src/backend/app/api/v1/agent/deps.py +++ b/src/backend/app/api/v1/agent/deps.py @@ -1,7 +1,7 @@ from fastapi import Request from app.agent.runner.executor import AgentExecutor -from app.agent.models.conversation_store import InMemoryConversationStore +from app.agent.models.conversation_store import ConversationStore def get_agent_executor(request: Request) -> AgentExecutor: @@ -11,7 +11,7 @@ def get_agent_executor(request: Request) -> AgentExecutor: return request.app.state.agent_executor -def get_conversation_store(request: Request) -> InMemoryConversationStore: +def get_conversation_store(request: Request) -> ConversationStore: """Dependency to get the global Conversation Store.""" if not hasattr(request.app.state, "conversation_store"): raise RuntimeError("Conversation store not initialized in app state.") diff --git a/src/backend/app/config/settings.py b/src/backend/app/config/settings.py index cf2a459a..bc60c4d5 100755 --- a/src/backend/app/config/settings.py +++ b/src/backend/app/config/settings.py @@ -5,7 +5,7 @@ class Settings(BaseSettings): - APP_ENV: str + APP_ENV: str = "development" TERMINUS_HOST: str TERMINUS_USER: str @@ -16,7 +16,7 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" model_config = SettingsConfigDict( - # Pydantic-Settings will automatically use the ENV_FILE env var if it exists. + # Pydantic-Settings will automatically use ENV_FILE when present. # Otherwise, it will fall back to ".env". env_file=os.environ.get("ENV_FILE", ".env"), env_file_encoding="utf-8", @@ -38,7 +38,8 @@ def get_settings() -> Settings: """ Returns a cached, singleton instance of the Settings. This function will only create the Settings object once. - It also ensures that the test environment variables are loaded if APP_ENV is set to 'test'. + It also ensures test environment variables are loaded + if APP_ENV is set to 'test'. """ env_file = os.environ.get("ENV_FILE", ".env") if os.environ.get("APP_ENV") == "test": diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 16a4c32c..72da776b 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -4,11 +4,15 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.socket.manager import get_socket_manager +from app.agent.config import settings as agent_settings from app.agent.runner.task_manager import TaskManager -from backend.app.agent.llm.factory import LLMFactory -from backend.app.agent.config import settings +from app.agent.llm.factory import LLMFactory from app.agent.llm.providers.openai_provider import OpenAIProvider -from backend.app.agent.runner.executor import AgentExecutor +from app.agent.models.conversation_store import ( + InMemoryConversationStore, + SQLiteConversationStore, +) +from app.agent.runner.executor import AgentExecutor from .api import root from .db.client import get_terminus_client, close_db_client @@ -36,14 +40,20 @@ async def lifespan(app: FastAPI): ) # 2. Initialize LLM Factory - llm_factory = LLMFactory(settings) - if settings.openai_api_key: + llm_factory = LLMFactory(agent_settings) + if agent_settings.openai_api_key: llm_factory.register_provider( - "openai", OpenAIProvider(api_key=settings.openai_api_key)) + "openai", OpenAIProvider(api_key=agent_settings.openai_api_key) + ) task_manager = TaskManager() -# 3. Initialize Conversation Store (Singleton for Phase 1 in-memory, or Repo for DB) - # conversation_store = InMemoryConversationStore() + # 3. Initialize conversation store (in-memory or sqlite) + if agent_settings.conversation_store_backend == "sqlite": + conversation_store = SQLiteConversationStore( + db_path=agent_settings.conversation_store_sqlite_path + ) + else: + conversation_store = InMemoryConversationStore() # 4. Create the Executor (wires runner, LLM, and store together) executor = AgentExecutor( @@ -57,7 +67,6 @@ async def lifespan(app: FastAPI): app.state.task_manager = task_manager app.state.conversation_store = conversation_store app.state.watcher_service = watcher_service - app.state.task_manager = task_manager # Init Socket Manager (creates the server instance) _ = get_socket_manager() From ae0718c9d338805931c714902f1023aa99d498f2 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 16:58:52 +0300 Subject: [PATCH 16/49] storage imporved --- src/backend/app/agent/models/conversation.py | 12 +- .../app/agent/models/conversation_store.py | 107 +++++++++++++++++- src/backend/app/agent/runner/executor.py | 53 +++++++-- src/backend/app/agent/runner/task_manager.py | 36 +++++- 4 files changed, 189 insertions(+), 19 deletions(-) diff --git a/src/backend/app/agent/models/conversation.py b/src/backend/app/agent/models/conversation.py index 7d0a5f1c..4efac936 100644 --- a/src/backend/app/agent/models/conversation.py +++ b/src/backend/app/agent/models/conversation.py @@ -28,7 +28,7 @@ class EventPart(BaseModel): type: Literal["event"] = "event" at: int event_type: str # "wait" | "click" | "focus" - payload: dict = {} + payload: dict = Field(default_factory=dict) class SubTaskState(str, Enum): @@ -49,7 +49,7 @@ class SubTask(BaseModel): finished_at: Optional[datetime] = None error: Optional[str] = None # TerminusDB node IDs modified by this step - touched_node_ids: list[str] = [] + touched_node_ids: list[str] = Field(default_factory=list) class TaskState(str, Enum): @@ -75,8 +75,8 @@ class TaskPart(BaseModel): started_at: Optional[datetime] = None finished_at: Optional[datetime] = None progress: float = 0.0 - sub_tasks: list[SubTask] = [] - touched_node_ids: list[str] = [] + sub_tasks: list[SubTask] = Field(default_factory=list) + touched_node_ids: list[str] = Field(default_factory=list) workflow_name: Optional[str] = None workflow_params: Optional[dict] = None @@ -105,5 +105,5 @@ class ConversationSummary(BaseModel): class Conversation(ConversationSummary): - messages: list[ConversationMessage] = [] - metadata: dict = {} # arbitrary metadata (e.g. linked project, node_id) + messages: list[ConversationMessage] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) diff --git a/src/backend/app/agent/models/conversation_store.py b/src/backend/app/agent/models/conversation_store.py index a4f1ebb6..a00b806e 100644 --- a/src/backend/app/agent/models/conversation_store.py +++ b/src/backend/app/agent/models/conversation_store.py @@ -11,6 +11,7 @@ Conversation, ConversationMessage, ConversationSummary, + TaskPart, ) @@ -36,6 +37,13 @@ def get_conversation(self, conversation_id: str) -> Conversation | None: def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: ... + def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + ... + class InMemoryConversationStore: """Simple process-local conversation store.""" @@ -105,11 +113,43 @@ def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: for item in sliced ] + def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + with self._lock: + conversation = self._conversations.get(conversation_id) + if conversation is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + for message in reversed(conversation.messages): + replaced = False + for idx, part in enumerate(message.parts): + if ( + isinstance(part, TaskPart) + and part.task_id == task_part.task_id + ): + message.parts[idx] = task_part + replaced = True + break + if replaced: + conversation.updated_at = datetime.utcnow() + return + + raise ValueError( + f"TaskPart not found for task_id={task_part.task_id} " + f"in conversation={conversation_id}" + ) + class SQLiteConversationStore: """SQLite-backed conversation store with the same interface.""" - def __init__(self, db_path: str): + def __init__( + self, + db_path: str, + ): self.db_path = db_path self._lock = threading.Lock() path = Path(db_path) @@ -201,7 +241,9 @@ def add_message( (conversation_id,), ).fetchone() if row is None: - raise ValueError(f"Conversation not found: {conversation_id}") + raise ValueError( + f"Conversation not found: {conversation_id}" + ) message_json = message.model_dump_json() created_at = datetime.utcnow().isoformat() @@ -231,7 +273,12 @@ def get_conversation( row = conn.execute( """ SELECT - id, title, description, metadata_json, created_at, updated_at + id, + title, + description, + metadata_json, + created_at, + updated_at FROM conversations WHERE id = ? """, @@ -303,3 +350,57 @@ def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: ) for row in rows ] + + def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + with self._lock: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, message_json + FROM conversation_messages + WHERE conversation_id = ? + ORDER BY created_at DESC + """, + (conversation_id,), + ).fetchall() + + for row in rows: + msg = ConversationMessage.model_validate_json( + row["message_json"] + ) + replaced = False + for idx, part in enumerate(msg.parts): + if ( + isinstance(part, TaskPart) + and part.task_id == task_part.task_id + ): + msg.parts[idx] = task_part + replaced = True + break + if not replaced: + continue + + conn.execute( + ( + "UPDATE conversation_messages " + "SET message_json = ? WHERE id = ?" + ), + (msg.model_dump_json(), row["id"]), + ) + conn.execute( + "UPDATE conversations SET updated_at = ? WHERE id = ?", + (datetime.utcnow().isoformat(), conversation_id), + ) + conn.commit() + return + + raise ValueError( + ( + f"TaskPart not found for task_id={task_part.task_id} " + f"in conversation={conversation_id}" + ) + ) diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index ebe1dfe3..c38173b8 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -4,8 +4,15 @@ from app.agent.runner.task_manager import TaskManager from app.agent.workflows.base import BaseWorkflow -from app.agent.models.conversation import ConversationMessage, MessageRole, SubTask, TaskPart, TextPart +from app.agent.models.conversation import ( + ConversationMessage, + MessageRole, + TaskPart, + TaskState as ConversationTaskState, + TextPart, +) from app.agent.models.conversation_store import ConversationStore +from app.agent.models.task_status import TaskStatus from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field @@ -37,6 +44,7 @@ def __init__( self.task_manager = task_manager self.llm_factory = llm_factory self.store = conversation_store + self._task_part_templates: dict[str, TaskPart] = {} async def run_workflow( self, @@ -59,6 +67,11 @@ async def run_workflow( task_id = self.task_manager.submit( name=f"workflow:{workflow.name}", coro_factory=workflow.run, + on_status_update=lambda status: self._update_task_part( + conversation_id, + task_id, + status, + ), **kwargs, ) @@ -76,6 +89,13 @@ async def run_workflow( parts=[TextPart(text=f"Starting {workflow.name}..."), task_part], ), ) + self._task_part_templates[task_id] = task_part + # Push initial state after the message has been written. + self._update_task_part( + conversation_id, + task_id, + self.task_manager.get_status(task_id), + ) return conversation_id, task_id async def _generate_title(self, workflow, params) -> tuple[str, str]: @@ -137,9 +157,28 @@ async def _generate_title(self, workflow, params) -> tuple[str, str]: # Never block workflow scheduling on title generation issues. return fallback_title, fallback_description - def _update_task_part(self, conv_id, task_part, sub_task: SubTask): - """Update TaskPart's sub_tasks list and push via WebSocket.""" - task_part.sub_tasks.append(sub_task) - task_part.touched_node_ids.extend(sub_task.touched_node_ids) - # Push real-time update via WebSocket - ... + def _update_task_part( + self, + conversation_id: str, + task_id: str, + task_status: TaskStatus | None, + ): + """Update existing TaskPart container for one workflow task.""" + if task_status is None: + return + + base_part = self._task_part_templates.get(task_id) + if base_part is None: + base_part = TaskPart(task_id=task_id, title=task_status.name) + + updated_part = base_part.model_copy( + update={ + "state": ConversationTaskState(task_status.state.value), + "progress": task_status.progress, + "description": task_status.progress_message or "", + "started_at": task_status.started_at, + "finished_at": task_status.finished_at, + } + ) + self.store.upsert_task_part(conversation_id, updated_part) + self._task_part_templates[task_id] = updated_part diff --git a/src/backend/app/agent/runner/task_manager.py b/src/backend/app/agent/runner/task_manager.py index 9277841f..74d836b6 100644 --- a/src/backend/app/agent/runner/task_manager.py +++ b/src/backend/app/agent/runner/task_manager.py @@ -1,8 +1,7 @@ import asyncio import uuid from datetime import datetime -from enum import Enum -from typing import Any, Callable, Coroutine, Optional +from typing import Any, Callable, Optional from app.agent.models.task_status import TaskStatus, TaskState @@ -17,6 +16,7 @@ def submit( self, name: str, coro_factory: Callable[..., Any], + on_status_update: Optional[Callable[[TaskStatus], None]] = None, **kwargs ) -> str: task_id = str(uuid.uuid4()) @@ -28,11 +28,34 @@ def submit( ) self._tasks[task_id] = status + update_interval_s = 0.5 + def _emit_status_update(): + if not on_status_update: + return + try: + on_status_update(status.model_copy(deep=True)) + except Exception: + # Status propagation should never crash task execution. + return + async def _wrapper(): status.state = TaskState.RUNNING status.started_at = datetime.utcnow() + _emit_status_update() + + reporter_task = None + if on_status_update: + async def _reporter(): + while status.state == TaskState.RUNNING: + _emit_status_update() + await asyncio.sleep(update_interval_s) + + reporter_task = asyncio.create_task(_reporter()) + try: - result = await coro_factory(**kwargs) + run_kwargs = dict(kwargs) + run_kwargs.setdefault("task_status", status) + result = await coro_factory(**run_kwargs) status.state = TaskState.COMPLETED status.result = result except asyncio.CancelledError: @@ -42,6 +65,13 @@ async def _wrapper(): status.error = str(e) finally: status.finished_at = datetime.utcnow() + if reporter_task: + reporter_task.cancel() + try: + await reporter_task + except asyncio.CancelledError: + pass + _emit_status_update() loop = asyncio.get_running_loop() atask = loop.create_task(_wrapper()) From 583f4241e9089376d9a43a024e05a57a7952e8a8 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 17:50:19 +0300 Subject: [PATCH 17/49] test added --- .../app/agent/context/graph_traversal.py | 53 ++++--- .../app/agent/workflows/description_gen.py | 98 ++++++++++--- .../app/agent/workflows/documentation_gen.py | 103 +++++++++---- .../e2e/agent/test_generation_workflows.py | 136 ++++++++++++++++++ src/backend/tests/e2e/conftest.py | 21 +-- src/backend/tree.json | 1 - 6 files changed, 333 insertions(+), 79 deletions(-) create mode 100644 src/backend/tests/e2e/agent/test_generation_workflows.py delete mode 100644 src/backend/tree.json diff --git a/src/backend/app/agent/context/graph_traversal.py b/src/backend/app/agent/context/graph_traversal.py index 96d9e224..d83e9b5d 100644 --- a/src/backend/app/agent/context/graph_traversal.py +++ b/src/backend/app/agent/context/graph_traversal.py @@ -1,6 +1,7 @@ from app.db.context import ProjectUoW from app.db.async_terminus_client import WOQLQuery as WQ from app.core.builder.tree_builder import TreeBuilder +from app.core.services.code_element_service import CodeElementService class GraphTraversal: @@ -19,7 +20,9 @@ class GraphTraversal: EDGE_PATTERN = "(" + "|".join(EDGE_FIELDS) + ")" def __init__(self, uow: ProjectUoW): + self.uow = uow self.repos = uow.get_project_repos() + self.code_service = CodeElementService(uow) def _extract_children(self, doc: dict) -> list[str]: children: list[str] = [] @@ -55,9 +58,10 @@ def _dedupe_nodes(self, nodes: list[dict]) -> list[dict]: unique[node_id] = node return list(unique.values()) + # TODO: Make imporve type filtering async def traverse_down( self, - node_id: str, + node_id: str | None, max_depth: int = 5, node_types: list[str] | None = None, ) -> list[dict]: @@ -65,6 +69,14 @@ async def traverse_down( Get all descendants from node_id and include the start node. Returns full node docs with normalized `id`, `type`, and `children`. """ + if not node_id: + all_nodes = await self.repos.project_repo.get_children(exclude_types=[]) + normalized_nodes = [ + self._normalize_doc(node.model_dump()) + for node in all_nodes + ] + return self._dedupe_nodes(normalized_nodes) + pattern = "+" if max_depth <= 0 else f"{{1,{max_depth}}}" query = ( WQ() @@ -126,9 +138,9 @@ async def traverse_up( return self._dedupe_nodes(nodes) - async def build_tree(self, node_id: str, max_depth: int = 5): + async def build_tree(self, node_id: str | None, node_types: list[str] | None = None, max_depth: int = 5): """Build nested tree nodes for subtree rooted at `node_id`.""" - nodes = await self.traverse_down(node_id=node_id, max_depth=max_depth) + nodes = await self.traverse_down(node_id=node_id, node_types=node_types, max_depth=max_depth) tree = TreeBuilder(base_nodes=nodes).build() return tree @@ -148,7 +160,7 @@ async def get_siblings(self, node_id: str) -> list[dict]: return [c for c in children if c["id"] not in {node_id, parent_id}] async def get_node_with_code(self, node_id: str) -> dict: - """Fetch node and hydrate file code content when linked.""" + """Fetch node and hydrate code via CodeElementService.get_code.""" if not self.repos.client: return {} @@ -156,20 +168,21 @@ async def get_node_with_code(self, node_id: str) -> dict: if not doc: return {} - code_ref = doc.get("code_content") - if not code_ref: - return doc - - if isinstance(code_ref, dict): - doc["code_content_data"] = code_ref.get("content", "") - return doc - - if isinstance(code_ref, str): - try: - code_doc = await self.repos.client.get_document(code_ref) - if code_doc: - doc["code_content_data"] = code_doc.get("content", "") - except Exception: - doc["code_content_data"] = "" - + try: + code_payload = await self.code_service.get_code(node_id) + if code_payload and code_payload.get("code"): + doc["code_content_data"] = code_payload["code"] + except Exception: + # Keep workflow robust for nodes that don't have code ranges. + doc["code_content_data"] = "" return doc + + async def get_code_content(self, node_id: str) -> str: + """Fetch only code content for a node without hydrating full doc.""" + try: + code_payload = await self.code_service.get_code(node_id) + if code_payload and code_payload.get("code"): + return code_payload["code"] + except Exception: + return "" + return "" diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index c30e0a32..ec65771c 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -3,6 +3,7 @@ from langchain_core.messages import HumanMessage +from app.db.async_terminus_client import WOQLQuery as WQ from app.agent.workflows.base import BaseWorkflow from app.agent.context.graph_traversal import GraphTraversal from app.agent.models.task_status import TaskStatus @@ -18,20 +19,29 @@ def __init__(self, graph: GraphTraversal | None = None, llm_factory=None): async def run( self, - node_id: str, - direction: str = "down", # "up" (leaf -> parent) | "down" (parent -> leaf) + node_id: str | None = None, + # "up" (leaf -> parent) | "down" (parent -> leaf) + direction: str = "down", max_depth: int = 5, task_status: TaskStatus | None = None, **kwargs, ): if self.graph is None: - raise ValueError("GraphTraversal is required for description workflow.") + raise ValueError( + "GraphTraversal is required for description workflow." + ) if self.llm_factory is None: - raise ValueError("LLM factory is required for description workflow.") + raise ValueError( + "LLM factory is required for description workflow.") if direction not in {"up", "down"}: raise ValueError(f"Invalid direction: {direction}") - roots = await self.graph.build_tree(node_id=node_id, max_depth=max_depth) + roots = await self.graph.build_tree( + node_id=node_id, + node_types=["FileSchema", "FunctionSchema", + "ClassSchema", "FolderSchema"], + max_depth=max_depth, + ) execution_nodes = self._ordered_nodes(roots=roots, direction=direction) total = len(execution_nodes) @@ -39,16 +49,15 @@ async def run( return {"processed": 0, "results": {}} generated_descriptions: dict[str, str] = {} - node_updates: dict[str, dict] = {} + node_updates: dict[str, Any] = {} for index, tree_node in enumerate(execution_nodes): node_id = getattr(tree_node, "id", None) if not node_id: continue - node_doc = await self.graph.get_node_with_code(node_id) - if not node_doc: - continue + node_doc = self._tree_node_to_prompt_doc(tree_node) + node_doc["code_content_data"] = await self.graph.get_code_content(node_id) child_descriptions = self._child_values( tree_node=tree_node, @@ -61,9 +70,9 @@ async def run( generated_description = await self._invoke_llm(prompt) generated_descriptions[node_id] = generated_description - updated_node_doc = dict(node_updates.get(node_id, node_doc)) - updated_node_doc["description"] = generated_description - node_updates[node_id] = updated_node_doc + node_updates[node_id] = tree_node.model_copy( + update={"description": generated_description} + ) if task_status: task_status.progress = (index + 1) / total @@ -132,6 +141,15 @@ def _child_values( values.append(child_value) return values + def _tree_node_to_prompt_doc(self, tree_node: Any) -> dict: + node_type = f"{tree_node.__class__.__name__.replace('TreeNode', 'Schema')}" + return { + "@id": getattr(tree_node, "id", None), + "@type": node_type, + "name": getattr(tree_node, "name", ""), + "description": getattr(tree_node, "description", ""), + } + async def _invoke_llm(self, prompt: str) -> str: llm = self.llm_factory.create(model="gpt-4o-mini") response = await llm.invoke([HumanMessage(content=prompt)]) @@ -167,7 +185,10 @@ def _build_description_prompt( "\n".join([f"- {item}" for item in child_descriptions]) if child_descriptions else "None" ) - code_context = self._extract_code_context(node_doc) or "No direct code content found." + code_context = ( + self._extract_code_context(node_doc) + or "No direct code content found." + ) return ( "Task: description\n" @@ -184,13 +205,50 @@ def _build_description_prompt( async def _flush_node_updates( self, *, - node_updates: dict[str, dict], + node_updates: dict[str, Any], ) -> None: - if not self.graph or not self.graph.repos.client: + if not self.graph: return - if node_updates: - await self.graph.repos.client.update_document( - list(node_updates.values()), - commit_msg=f"Workflow: update {len(node_updates)} node descriptions", - ) + if not node_updates: + return + + client = self.graph.repos.client + if not client: + return + + for node in node_updates.values(): + node_id = getattr(node, "id", None) + if not node_id: + continue + + queries = [] + if hasattr(node, "description"): + queries.extend( + [ + WQ().opt( + WQ() + .triple(node_id, "description", "v:old_description") + .delete_triple( + node_id, "description", "v:old_description" + ) + ), + WQ().add_triple( + node_id, + "description", + WQ().string(getattr(node, "description", "") or ""), + ), + ] + ) + + if hasattr(node, "documents"): + for document_id in set(getattr(node, "documents") or set()): + queries.append( + WQ().add_triple(node_id, "documents", document_id) + ) + + if queries: + await client.query( + WQ().woql_and(*queries), + commit_msg=f"Workflow: update node {node_id}", + ) diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py index 88afe914..6c119c25 100644 --- a/src/backend/app/agent/workflows/documentation_gen.py +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -14,7 +14,7 @@ def __init__(self, graph: GraphTraversal | None = None, llm_factory=None): async def run( self, - node_id: str, + node_id: str | None = None, direction: str = "down", max_depth: int = 5, task_status: TaskStatus | None = None, @@ -23,7 +23,9 @@ async def run( # Documentation starts only after description phase finishes. if task_status: task_status.progress = 0.0 - task_status.progress_message = "Generating descriptions before documentation..." + task_status.progress_message = ( + "Generating descriptions before documentation..." + ) description_result = await super().run( node_id=node_id, @@ -33,15 +35,29 @@ async def run( ) if self.graph is None: - raise ValueError("GraphTraversal is required for documentation workflow.") + raise ValueError( + "GraphTraversal is required for documentation workflow." + ) - roots = await self.graph.build_tree(node_id=node_id, max_depth=max_depth) + roots = await self.graph.build_tree( + node_id=node_id, + node_types=["FileSchema", "FunctionSchema", + "ClassSchema", "FolderSchema"], + max_depth=max_depth, + ) execution_nodes = self._ordered_nodes(roots=roots, direction=direction) if not execution_nodes: - return {"processed": 0, "documentation_results": {}, "upserted_document_ids": []} - - description_values: dict[str, str] = description_result.get("description_results", {}) + return { + "processed": 0, + "documentation_results": {}, + "upserted_document_ids": [], + } + + description_values: dict[str, str] = description_result.get( + "description_results", {} + ) documentation_values: dict[str, str] = {} + processed_nodes: dict[str, object] = {} total = len(execution_nodes) for index, tree_node in enumerate(execution_nodes): @@ -49,9 +65,10 @@ async def run( if not current_node_id: continue - node_doc = await self.graph.get_node_with_code(current_node_id) - if not node_doc: - continue + node_doc = self._tree_node_to_prompt_doc(tree_node) + node_doc["code_content_data"] = await self.graph.get_code_content( + current_node_id + ) child_documentations = self._child_values( tree_node=tree_node, @@ -64,20 +81,30 @@ async def run( prompt = self._build_documentation_prompt( node_doc=node_doc, - node_description=description_values.get(current_node_id, node_doc.get("description", "")), + node_description=description_values.get( + current_node_id, + node_doc.get("description", ""), + ), child_documentations=child_documentations, child_descriptions=child_descriptions, ) - documentation_values[current_node_id] = await self._invoke_llm(prompt) + documentation_values[current_node_id] = await self._invoke_llm( + prompt + ) + processed_nodes[current_node_id] = tree_node if task_status: phase_progress = (index + 1) / total task_status.progress = 0.5 + (phase_progress * 0.5) task_status.progress_message = ( - f"Generated documentation: {node_doc.get('name', current_node_id)}" + "Generated documentation: " + f"{node_doc.get('name', current_node_id)}" ) - upserted_doc_ids = await self._flush_documentation_batch(documentation_values) + upserted_doc_ids = await self._flush_documentation_batch( + documentation_values, + processed_nodes, + ) return { "processed": len(documentation_values), "direction": direction, @@ -102,12 +129,16 @@ def _build_documentation_prompt( "\n".join([f"- {item}" for item in child_descriptions]) if child_descriptions else "None" ) - code_context = self._extract_code_context(node_doc) or "No direct code content found." + code_context = ( + self._extract_code_context(node_doc) + or "No direct code content found." + ) return ( "Task: documentation\n" "Write practical technical documentation for this node.\n" - "Use node description and child outputs to keep hierarchy-consistent docs.\n\n" + "Use node description and child outputs to keep " + "hierarchy-consistent docs.\n\n" f"Node id: {node_doc.get('@id')}\n" f"Node type: {node_doc.get('@type')}\n" f"Node name: {node_doc.get('name')}\n" @@ -122,13 +153,24 @@ def _documentation_doc_id(node_id: str) -> str: safe = node_id.replace("/", "_").replace(":", "_") return f"DocumentSchema/{safe}_workflow_documentation" - async def _flush_documentation_batch(self, documentation_values: dict[str, str]) -> list[str]: - if not self.graph or not self.graph.repos.client or not documentation_values: + async def _flush_documentation_batch( + self, + documentation_values: dict[str, str], + processed_nodes: dict[str, object], + ) -> list[str]: + if ( + not self.graph + or not self.graph.repos.client + or not documentation_values + ): return [] client = self.graph.repos.client now = datetime.now(timezone.utc) - doc_ids = [self._documentation_doc_id(node_id) for node_id in documentation_values] + doc_ids = [ + self._documentation_doc_id(node_id) + for node_id in documentation_values + ] existing_docs: dict[str, dict] = {} try: @@ -138,7 +180,7 @@ async def _flush_documentation_batch(self, documentation_values: dict[str, str]) existing_docs = {} documents_to_upsert: list[DocumentSchema] = [] - node_updates: list[dict] = [] + node_updates: dict[str, object] = {} for node_id, content in documentation_values.items(): doc_id = self._documentation_doc_id(node_id) @@ -156,11 +198,11 @@ async def _flush_documentation_batch(self, documentation_values: dict[str, str]) ) ) - node_doc = await self.graph.get_node_with_code(node_id) - if not node_doc: + tree_node = processed_nodes.get(node_id) + if tree_node is None: continue - current_docs = node_doc.get("documents") + current_docs = getattr(tree_node, "documents", None) if isinstance(current_docs, set): docs_set = set(current_docs) elif isinstance(current_docs, list): @@ -170,17 +212,18 @@ async def _flush_documentation_batch(self, documentation_values: dict[str, str]) else: docs_set = set() docs_set.add(doc_id) - node_doc["documents"] = docs_set - node_updates.append(node_doc) + node_updates[node_id] = tree_node.model_copy( + update={"documents": docs_set} + ) await client.update_document( documents_to_upsert, - commit_msg=f"Workflow: upsert {len(documents_to_upsert)} generated documents", + commit_msg=( + f"Workflow: upsert {len(documents_to_upsert)} " + "generated documents" + ), ) if node_updates: - await client.update_document( - node_updates, - commit_msg=f"Workflow: update {len(node_updates)} node document links", - ) + await self._flush_node_updates(node_updates=node_updates) return doc_ids diff --git a/src/backend/tests/e2e/agent/test_generation_workflows.py b/src/backend/tests/e2e/agent/test_generation_workflows.py new file mode 100644 index 00000000..6b5d325b --- /dev/null +++ b/src/backend/tests/e2e/agent/test_generation_workflows.py @@ -0,0 +1,136 @@ +import pytest + +from app.agent.context.graph_traversal import GraphTraversal +from app.agent.workflows.description_gen import DescriptionGeneratorWorkflow +from app.agent.workflows.documentation_gen import ( + DocumentationGeneratorWorkflow, +) +from app.db.context import ProjectUoW, RequestDbContext + + +class _FakeResponse: + def __init__(self, content: str): + self.content = content + + +class _FakeLLMProvider: + async def invoke(self, messages, **kwargs): + prompt = messages[-1].content if messages else "" + node_name = "unknown" + for line in prompt.splitlines(): + if line.startswith("Node name: "): + node_name = line.replace("Node name: ", "").strip() + break + + if "Task: documentation" in prompt: + return _FakeResponse(f"DOC::{node_name}") + return _FakeResponse(f"DESC::{node_name}") + + +class _FakeLLMFactory: + def create(self, **kwargs): + return _FakeLLMProvider() + + +def _doc_id_for_node(node_id: str) -> str: + safe = node_id.replace("/", "_").replace(":", "_") + return f"DocumentSchema/{safe}_workflow_documentation" + + +async def _get_main_file_node(uow: ProjectUoW): + repos = uow.get_project_repos() + file_nodes = await repos.structure_repo.get_by_qnames( + ["sample_project.main"], + "FileSchema", + ) + return file_nodes["sample_project.main"] + + +@pytest.mark.asyncio +async def test_description_workflow_updates_node_descriptions( + built_sample_project, + terminusdb_client, +): + project_node, _ = built_sample_project + uow = ProjectUoW(terminusdb_client, project_node, RequestDbContext()) + graph = GraphTraversal(uow) + workflow = DescriptionGeneratorWorkflow( + graph=graph, + llm_factory=_FakeLLMFactory(), + ) + + main_node = await _get_main_file_node(uow) + result = await workflow.run(node_id=main_node.id, direction="up", max_depth=1) + assert result["processed"] > 0 + + repos = uow.get_project_repos() + raw_main_doc = await repos.client.get_document(main_node.id) + assert raw_main_doc["description"].startswith("DESC::") + + +@pytest.mark.asyncio +async def test_documentation_workflow_creates_documents_and_links( + built_sample_project, + terminusdb_client, +): + project_node, _ = built_sample_project + uow = ProjectUoW(terminusdb_client, project_node, RequestDbContext()) + graph = GraphTraversal(uow) + workflow = DocumentationGeneratorWorkflow( + graph=graph, + llm_factory=_FakeLLMFactory(), + ) + + main_node = await _get_main_file_node(uow) + result = await workflow.run(node_id=main_node.id, direction="up", max_depth=1) + assert result["processed"] > 0 + assert len(result["upserted_document_ids"]) > 0 + + repos = uow.get_project_repos() + raw_main_doc = await repos.client.get_document(main_node.id) + assert raw_main_doc["description"].startswith("DESC::") + + expected_doc_id = _doc_id_for_node(main_node.id) + assert expected_doc_id in set(raw_main_doc.get("documents", [])) + generated_doc = await repos.client.get_document(expected_doc_id) + assert generated_doc["data"].startswith("DOC::") + + +@pytest.mark.asyncio +async def test_combined_description_then_documentation_flow( + built_sample_project, + terminusdb_client, +): + project_node, _ = built_sample_project + uow = ProjectUoW(terminusdb_client, project_node, RequestDbContext()) + graph = GraphTraversal(uow) + desc_workflow = DescriptionGeneratorWorkflow( + graph=graph, + llm_factory=_FakeLLMFactory(), + ) + doc_workflow = DocumentationGeneratorWorkflow( + graph=graph, + llm_factory=_FakeLLMFactory(), + ) + + main_node = await _get_main_file_node(uow) + desc_result = await desc_workflow.run( + node_id=main_node.id, + direction="up", + max_depth=1, + ) + doc_result = await doc_workflow.run( + node_id=main_node.id, + direction="up", + max_depth=1, + ) + + assert desc_result["processed"] > 0 + assert doc_result["processed"] > 0 + + repos = uow.get_project_repos() + raw_main_doc = await repos.client.get_document(main_node.id) + expected_doc_id = _doc_id_for_node(main_node.id) + + assert raw_main_doc["description"].startswith("DESC::") + assert expected_doc_id in set(raw_main_doc.get("documents", [])) diff --git a/src/backend/tests/e2e/conftest.py b/src/backend/tests/e2e/conftest.py index 7f5fd01d..a8ea6164 100644 --- a/src/backend/tests/e2e/conftest.py +++ b/src/backend/tests/e2e/conftest.py @@ -8,6 +8,7 @@ from app.db.client import get_terminus_client from app.core.services.project_service import ProjectService from app.db.async_terminus_client import AsyncClient as TerminusClient +from app.db.context import ProjectUoW, RequestDbContext from app.core.parser.graph_builder.orchestrator import GraphBuilderOrchestrator @@ -42,24 +43,26 @@ def sample_project_path(tmp_path): @pytest_asyncio.fixture -async def built_sample_project(sample_project_path, create_repos, terminusdb_client): +async def built_sample_project(sample_project_path, terminusdb_client): """Creates a project and runs GraphBuilder to populate structure (no API).""" - project_service = ProjectService(create_repos) - print(f"Creating sample project at: {create_repos.client.db}") + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) + print(f"Creating sample project at: {terminusdb_client.db}") project_node = await project_service.create( "sample_project", "A sample project for E2E tests", sample_project_path, ) - clone_db = terminusdb_client.clone() + project_uow = ProjectUoW(terminusdb_client, project_node, ctx) orchestrator = GraphBuilderOrchestrator( project_node=project_node, - db=clone_db, + uow=project_uow, ignore_file_name=".gitignore", ) await orchestrator.resync() - yield project_node, create_repos + yield project_node, project_uow await project_service.delete(project_node.id) @@ -77,8 +80,10 @@ async def sample_project_node(empty_project_uow): @pytest_asyncio.fixture -async def created_sample_project(create_repos): - project_service = ProjectService(create_repos) +async def created_sample_project(terminusdb_client): + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) return await project_service.create( "sample_project", "A sample project for E2E tests", diff --git a/src/backend/tree.json b/src/backend/tree.json deleted file mode 100644 index 4995490b..00000000 --- a/src/backend/tree.json +++ /dev/null @@ -1 +0,0 @@ -[{"id": "FolderSchema/3d22733e-1c90-456b-ba1e-23b25e3773f1", "name": "examples", "description": "Folder examples", "created_at": "2026-03-02T13:26:09.779352Z", "updated_at": "2026-03-02T13:26:09.779353Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/examples", "qname": "sample_project2.examples", "children": [{"id": "FileSchema/54bc4038-6004-40fc-988c-3c863914e98d", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.907007Z", "updated_at": "2026-03-02T13:26:09.907007Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/examples/__init__.py", "qname": "sample_project2.examples.__init__", "documents": [], "theme_config": null, "hash": "80d9c527e809cc99728abc469c1cf6d2d34bdf579d7034002d3d30a141b31312", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/54bc4038-6004-40fc-988c-3c863914e98d"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/76e5c73d-e1bf-47b8-9d05-814dc171f10b", "name": "core", "description": "Folder core", "created_at": "2026-03-02T13:26:09.779106Z", "updated_at": "2026-03-02T13:26:09.779107Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core", "qname": "sample_project2.core", "children": [{"id": "FolderSchema/e14f9027-d43a-4701-aca9-442f61d5dc0d", "name": "utils", "description": "Folder utils", "created_at": "2026-03-02T13:26:09.779272Z", "updated_at": "2026-03-02T13:26:09.779272Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils", "qname": "sample_project2.core.utils", "children": [{"id": "FileSchema/8d48173b-d3c6-4264-9a45-a8bcabc8a17d", "name": "helper", "description": "File helper", "created_at": "2026-03-02T13:26:09.906969Z", "updated_at": "2026-03-02T13:26:09.906969Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/helper.py", "qname": "sample_project2.core.utils.helper", "documents": [], "theme_config": null, "hash": "6fd86108098a2fc3d29460d8667fa7f9f24e0dfaebb881a700a1dbaaf9484091", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "FunctionSchema/6e445d46-a576-41e8-a8ee-223eadd6808b"], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/2dee774d-27b7-404f-958f-18963182abb3", "name": "Child", "description": "call::model.child.Child", "created_at": "2026-03-02T13:26:12.529052Z", "updated_at": "2026-03-02T13:26:12.529101Z", "qname": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/c021ef6f-92ac-44a1-b4a3-bb76fce8a74d"], "call_group": []}, "children": [{"id": "CallSchema/c021ef6f-92ac-44a1-b4a3-bb76fce8a74d", "name": "__init__", "description": "call::model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.529120Z", "updated_at": "2026-03-02T13:26:12.529120Z", "qname": "CallSchema/2dee774d-27b7-404f-958f-18963182abb3::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/6e445d46-a576-41e8-a8ee-223eadd6808b", "name": "gg", "description": "Function gg", "created_at": "2026-03-02T13:26:10.428986Z", "updated_at": "2026-03-02T13:26:10.428986Z", "qname": "sample_project2.core.utils.helper.gg", "code_position": {"line_no": 18, "col_offset": 0, "end_line_no": 21, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "FolderSchema/058cc172-12ef-41c8-80b8-247ef2337813", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779317Z", "updated_at": "2026-03-02T13:26:09.779317Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__pycache__", "qname": "sample_project2.core.utils.__pycache__", "children": [{"id": "FileSchema/10a1e587-1157-4b8f-b191-fbd1b50f14d6", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906929Z", "updated_at": "2026-03-02T13:26:09.906929Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__pycache__/__init__.py", "qname": "sample_project2.core.utils.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "ab7503b84b86a9f713beb5313850ca98b29b3be9cf8c8d0c361928de69ab76f2", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/10a1e587-1157-4b8f-b191-fbd1b50f14d6"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/435e26f0-b71c-44f3-aa2b-79696098d670", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906886Z", "updated_at": "2026-03-02T13:26:09.906887Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/utils/__init__.py", "qname": "sample_project2.core.utils.__init__", "documents": [], "theme_config": null, "hash": "1fddc5d2e9a38472a7742045cd7ee27f260c12692f29a48320f1ba0b39241f71", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/058cc172-12ef-41c8-80b8-247ef2337813"], "file_children": ["FileSchema/8d48173b-d3c6-4264-9a45-a8bcabc8a17d", "FileSchema/435e26f0-b71c-44f3-aa2b-79696098d670"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/cbf0fded-9500-460a-9054-15124baebf48", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906626Z", "updated_at": "2026-03-02T13:26:09.906626Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__init__.py", "qname": "sample_project2.core.__init__", "documents": [], "theme_config": null, "hash": "6c509d059d23cdfdd2f3461bdba74025dd3e077b97fd61083fcc402ffa5be893", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FolderSchema/1ff2573f-25df-4952-8067-a5bd3544cb06", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779151Z", "updated_at": "2026-03-02T13:26:09.779151Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__pycache__", "qname": "sample_project2.core.__pycache__", "children": [{"id": "FileSchema/1760100c-b635-49c7-8617-9c2edceff84a", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906672Z", "updated_at": "2026-03-02T13:26:09.906672Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/__pycache__/__init__.py", "qname": "sample_project2.core.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "94f2066efc299954374d47b58983d7b5c20b34bb05730908df65444bd2c87e77", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/1760100c-b635-49c7-8617-9c2edceff84a"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/918c5c9e-e649-4866-aa7d-507947231cf6", "name": "model", "description": "Folder model", "created_at": "2026-03-02T13:26:09.779191Z", "updated_at": "2026-03-02T13:26:09.779192Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model", "qname": "sample_project2.core.model", "children": [{"id": "FileSchema/a65973ab-fdae-448b-a241-eed927d0a84e", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906716Z", "updated_at": "2026-03-02T13:26:09.906716Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__init__.py", "qname": "sample_project2.core.model.__init__", "documents": [], "theme_config": null, "hash": "7676e96e7ac4f4cd04247c8a7d4b5d64deccd1ca9037d53dd1e4a1dc1305a3a6", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FolderSchema/01530536-7659-4cc0-92e7-c5a4dc7d9cf3", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779234Z", "updated_at": "2026-03-02T13:26:09.779234Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__pycache__", "qname": "sample_project2.core.model.__pycache__", "children": [{"id": "FileSchema/21159768-34c4-48a3-ad1b-a38b1805a0df", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906763Z", "updated_at": "2026-03-02T13:26:09.906763Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/__pycache__/__init__.py", "qname": "sample_project2.core.model.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "a1778df505e49d9619897713ed11c980f370c132ddb4668df26cc68829d22672", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/21159768-34c4-48a3-ad1b-a38b1805a0df"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/4ea5dc9c-650e-4936-ace4-97d8dd11c1c6", "name": "parent", "description": "File parent", "created_at": "2026-03-02T13:26:09.906846Z", "updated_at": "2026-03-02T13:26:09.906847Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/parent.py", "qname": "sample_project2.core.model.parent", "documents": [], "theme_config": null, "hash": "c3a597a53d21e8aac2f963afa247e06925abecf1cb0f43644a496725347f5b87", "children_by_type": {"class_children": ["ClassSchema/e82dc2ba-b511-4096-91c2-c7f97f312c45", "ClassSchema/e61d4fc3-681d-4b64-9576-deb169ce1ba1", "ClassSchema/0aef4dcd-59eb-4b0a-82c1-dc3b71e55d22"], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "ClassSchema/e82dc2ba-b511-4096-91c2-c7f97f312c45", "name": "Uncle", "description": "Class Uncle", "created_at": "2026-03-02T13:26:10.458623Z", "updated_at": "2026-03-02T13:26:10.458623Z", "qname": "sample_project2.core.model.parent.Uncle", "code_position": {"line_no": 24, "col_offset": 0, "end_line_no": 41, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/2c33578a-b0ea-4c32-a707-ef8ee4be0fed", "FunctionSchema/6f0276d2-c919-4e74-9f0f-16b1c008ae28", "FunctionSchema/fab8038a-5741-42ce-8410-217dc4a1afc6"], "code_element_group": []}, "children": [{"id": "FunctionSchema/2c33578a-b0ea-4c32-a707-ef8ee4be0fed", "name": "run", "description": "Function run", "created_at": "2026-03-02T13:26:10.458639Z", "updated_at": "2026-03-02T13:26:10.458639Z", "qname": "sample_project2.core.model.parent.Uncle.run", "code_position": {"line_no": 38, "col_offset": 4, "end_line_no": 41, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/fab8038a-5741-42ce-8410-217dc4a1afc6", "name": "walk", "description": "Function walk", "created_at": "2026-03-02T13:26:10.458634Z", "updated_at": "2026-03-02T13:26:10.458634Z", "qname": "sample_project2.core.model.parent.Uncle.walk", "code_position": {"line_no": 33, "col_offset": 4, "end_line_no": 36, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/6f0276d2-c919-4e74-9f0f-16b1c008ae28", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458629Z", "updated_at": "2026-03-02T13:26:10.458629Z", "qname": "sample_project2.core.model.parent.Uncle.get_name", "code_position": {"line_no": 28, "col_offset": 4, "end_line_no": 31, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.parent.Uncle"], "theme_config": null, "node_type": "class"}, {"id": "ClassSchema/e61d4fc3-681d-4b64-9576-deb169ce1ba1", "name": "Parent", "description": "Class Parent", "created_at": "2026-03-02T13:26:10.458644Z", "updated_at": "2026-03-02T13:26:10.458644Z", "qname": "sample_project2.core.model.parent.Parent", "code_position": {"line_no": 43, "col_offset": 0, "end_line_no": 55, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/a2755ca0-36f8-4766-a0f2-65578edeb4ba", "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46"], "code_element_group": []}, "children": [{"id": "FunctionSchema/a2755ca0-36f8-4766-a0f2-65578edeb4ba", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458653Z", "updated_at": "2026-03-02T13:26:10.458653Z", "qname": "sample_project2.core.model.parent.Parent.get_name", "code_position": {"line_no": 52, "col_offset": 4, "end_line_no": 55, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.458648Z", "updated_at": "2026-03-02T13:26:10.458649Z", "qname": "sample_project2.core.model.parent.Parent.__init__", "code_position": {"line_no": 47, "col_offset": 4, "end_line_no": 50, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.parent.Parent"], "theme_config": null, "node_type": "class"}, {"id": "ClassSchema/0aef4dcd-59eb-4b0a-82c1-dc3b71e55d22", "name": "GrandParent", "description": "Class GrandParent", "created_at": "2026-03-02T13:26:10.458598Z", "updated_at": "2026-03-02T13:26:10.458599Z", "qname": "sample_project2.core.model.parent.GrandParent", "code_position": {"line_no": 5, "col_offset": 0, "end_line_no": 22, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f524bf69-3e1f-4d6d-906e-f8cdf912292b", "FunctionSchema/e3a37c00-22fd-490c-b94d-dd39e6d94859", "FunctionSchema/dfbbacc3-b91d-4e51-8e6c-72c6f9d93f66"], "code_element_group": []}, "children": [{"id": "FunctionSchema/f524bf69-3e1f-4d6d-906e-f8cdf912292b", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.458605Z", "updated_at": "2026-03-02T13:26:10.458606Z", "qname": "sample_project2.core.model.parent.GrandParent.get_name", "code_position": {"line_no": 9, "col_offset": 4, "end_line_no": 12, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/e3a37c00-22fd-490c-b94d-dd39e6d94859", "name": "walk", "description": "Function walk", "created_at": "2026-03-02T13:26:10.458611Z", "updated_at": "2026-03-02T13:26:10.458612Z", "qname": "sample_project2.core.model.parent.GrandParent.walk", "code_position": {"line_no": 14, "col_offset": 4, "end_line_no": 17, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/dfbbacc3-b91d-4e51-8e6c-72c6f9d93f66", "name": "sleep", "description": "Function sleep", "created_at": "2026-03-02T13:26:10.458617Z", "updated_at": "2026-03-02T13:26:10.458617Z", "qname": "sample_project2.core.model.parent.GrandParent.sleep", "code_position": {"line_no": 19, "col_offset": 4, "end_line_no": 22, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["builtins.object", "sample_project2.core.model.parent.GrandParent"], "theme_config": null, "node_type": "class"}], "node_type": "file"}, {"id": "FileSchema/e1f8d40d-0e99-45e4-ad7d-5d9140215da0", "name": "child", "description": "File child", "created_at": "2026-03-02T13:26:09.906805Z", "updated_at": "2026-03-02T13:26:09.906806Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/core/model/child.py", "qname": "sample_project2.core.model.child", "documents": [], "theme_config": null, "hash": "64ec72e2ee88770c87ff55c2ac475e3bc5b15a56a31c99051be701567d0e5ad2", "children_by_type": {"class_children": ["ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0"], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [{"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/5e424edb-664d-486e-93ff-676d16d52682", "name": "__init__", "description": "call::parent.Parent.__init__", "created_at": "2026-03-02T13:26:12.530042Z", "updated_at": "2026-03-02T13:26:12.530042Z", "qname": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326::FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "target_function": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/321fc4ad-2dab-43e1-80f6-349c0fbeee46", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.458648Z", "updated_at": "2026-03-02T13:26:10.458649Z", "qname": "sample_project2.core.model.parent.Parent.__init__", "code_position": {"line_no": 47, "col_offset": 4, "end_line_no": 50, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "name": "set_name", "description": "Function set_name", "created_at": "2026-03-02T13:26:10.443308Z", "updated_at": "2026-03-02T13:26:10.443309Z", "qname": "sample_project2.core.model.child.Child.set_name", "code_position": {"line_no": 21, "col_offset": 4, "end_line_no": 24, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2", "name": "fly", "description": "Function fly", "created_at": "2026-03-02T13:26:10.443313Z", "updated_at": "2026-03-02T13:26:10.443314Z", "qname": "sample_project2.core.model.child.Child.fly", "code_position": {"line_no": 25, "col_offset": 4, "end_line_no": 28, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "class"}], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/01530536-7659-4cc0-92e7-c5a4dc7d9cf3"], "file_children": ["FileSchema/4ea5dc9c-650e-4936-ace4-97d8dd11c1c6", "FileSchema/a65973ab-fdae-448b-a241-eed927d0a84e", "FileSchema/e1f8d40d-0e99-45e4-ad7d-5d9140215da0"], "structure_group": []}, "theme_config": null, "node_type": "folder"}], "documents": [], "children_by_type": {"folder_children": ["FolderSchema/e14f9027-d43a-4701-aca9-442f61d5dc0d", "FolderSchema/1ff2573f-25df-4952-8067-a5bd3544cb06", "FolderSchema/918c5c9e-e649-4866-aa7d-507947231cf6"], "file_children": ["FileSchema/cbf0fded-9500-460a-9054-15124baebf48"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FolderSchema/f82bc8a6-37d5-426c-9113-b93b7d35d60b", "name": "__pycache__", "description": "Folder __pycache__", "created_at": "2026-03-02T13:26:09.779050Z", "updated_at": "2026-03-02T13:26:09.779054Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__pycache__", "qname": "sample_project2.__pycache__", "children": [{"id": "FileSchema/e45bcf03-780a-4fa9-8f27-a8f47649f8f8", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906580Z", "updated_at": "2026-03-02T13:26:09.906581Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__pycache__/__init__.py", "qname": "sample_project2.__pycache__.__init__", "documents": [], "theme_config": null, "hash": "b172fe08b6a8f13192e597ae8a330cad4c64894da282e026521b71a5268ff8df", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}], "documents": [], "children_by_type": {"folder_children": [], "file_children": ["FileSchema/e45bcf03-780a-4fa9-8f27-a8f47649f8f8"], "structure_group": []}, "theme_config": null, "node_type": "folder"}, {"id": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1", "name": "main", "description": "File main", "created_at": "2026-03-02T13:26:09.907105Z", "updated_at": "2026-03-02T13:48:03.774381Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/main.py", "qname": "sample_project2.main", "documents": [], "theme_config": null, "hash": "6427c103950b47425509a6866acce7a74b06d3943886614004d65a9e19413f95", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "FunctionSchema/fb04a14a-2746-4212-8bdd-cb70779c416c"], "code_element_group": [], "call_children": ["CallSchema/921155ba-86ca-43f9-95e4-489f233dac16", "CallSchema/65834d2b-1f81-4c13-85e0-5f4416baba36"], "call_group": []}, "children": [{"id": "FunctionSchema/fb04a14a-2746-4212-8bdd-cb70779c416c", "name": "runner", "description": "Function runner", "created_at": "2026-03-02T13:26:10.466360Z", "updated_at": "2026-03-02T13:26:10.466360Z", "qname": "sample_project2.main.runner", "code_position": {"line_no": 16, "col_offset": 0, "end_line_no": 20, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16", "name": "main", "description": "call::main.main", "created_at": "2026-03-02T13:26:12.529728Z", "updated_at": "2026-03-02T13:26:12.529729Z", "qname": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1::FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "target_function": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "children_by_type": {"call_children": ["CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2", "CallSchema/5ec80657-2dc9-47ae-b4c6-fa65074992cb"], "call_group": []}, "children": [{"id": "CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2", "name": "create_child", "description": "call::core.utils.helper.create_child", "created_at": "2026-03-02T13:26:12.529748Z", "updated_at": "2026-03-02T13:26:12.529748Z", "qname": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16::FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "target_function": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "children_by_type": {"call_children": ["CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89"], "call_group": []}, "children": [{"id": "CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89", "name": "Child", "description": "call::sample_project2.core.model.child.Child", "created_at": "2026-03-02T13:48:04.296251Z", "updated_at": "2026-03-02T13:48:04.296252Z", "qname": "CallSchema/186ae128-9a0d-4312-9e32-c9410750a2f2::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/40f90d8c-e69f-42df-b250-9a00a0ff09f1"], "call_group": []}, "children": [{"id": "CallSchema/40f90d8c-e69f-42df-b250-9a00a0ff09f1", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:48:04.296267Z", "updated_at": "2026-03-02T13:48:04.296268Z", "qname": "CallSchema/c216e1f6-e207-47be-a8c7-ac9ccbf52d89::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/5ec80657-2dc9-47ae-b4c6-fa65074992cb", "name": "get_name", "description": "call::core.model.child.Child.get_name", "created_at": "2026-03-02T13:26:12.529741Z", "updated_at": "2026-03-02T13:26:12.529742Z", "qname": "CallSchema/921155ba-86ca-43f9-95e4-489f233dac16::FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "target_function": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "name": "main", "description": "Function main", "created_at": "2026-03-02T13:26:10.466354Z", "updated_at": "2026-03-02T13:26:10.466354Z", "qname": "sample_project2.main.main", "code_position": {"line_no": 8, "col_offset": 0, "end_line_no": 13, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/65834d2b-1f81-4c13-85e0-5f4416baba36", "name": "dd", "description": "call::sample_project2.main.dd", "created_at": "2026-03-02T13:48:04.296215Z", "updated_at": "2026-03-02T13:48:04.296219Z", "qname": "FileSchema/372f9c6e-7ea2-44ed-9678-b56a0ca8c3f1::FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "target_function": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "name": "dd", "description": "Function dd", "created_at": "2026-03-02T13:26:10.466365Z", "updated_at": "2026-03-02T13:26:10.466365Z", "qname": "sample_project2.main.dd", "code_position": {"line_no": 22, "col_offset": 0, "end_line_no": 26, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "FunctionSchema/08a9a5f5-aa69-4a67-bf4a-e10800e80e31", "name": "dd", "description": "Function dd", "created_at": "2026-03-02T13:26:10.466365Z", "updated_at": "2026-03-02T13:26:10.466365Z", "qname": "sample_project2.main.dd", "code_position": {"line_no": 22, "col_offset": 0, "end_line_no": 26, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}, {"id": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87", "name": "main", "description": "Function main", "created_at": "2026-03-02T13:26:10.466354Z", "updated_at": "2026-03-02T13:26:10.466354Z", "qname": "sample_project2.main.main", "code_position": {"line_no": 8, "col_offset": 0, "end_line_no": 13, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [{"id": "CallSchema/99e22f7e-4efe-4457-8b1b-fbb20002788d", "name": "get_name", "description": "call::core.model.child.Child.get_name", "created_at": "2026-03-02T13:26:12.642096Z", "updated_at": "2026-03-02T13:26:12.642098Z", "qname": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87::FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "target_function": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "name": "get_name", "description": "Function get_name", "created_at": "2026-03-02T13:26:10.443304Z", "updated_at": "2026-03-02T13:26:10.443304Z", "qname": "sample_project2.core.model.child.Child.get_name", "code_position": {"line_no": 16, "col_offset": 4, "end_line_no": 19, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/74062b63-0b55-40f5-a107-060e7aa546c2", "name": "create_child", "description": "call::core.utils.helper.create_child", "created_at": "2026-03-02T13:26:12.642113Z", "updated_at": "2026-03-02T13:26:12.642113Z", "qname": "FunctionSchema/11b1c19e-faa5-44f1-836a-c1ef80d43c87::FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "target_function": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "children_by_type": {"call_children": ["CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786"], "call_group": []}, "children": [{"id": "CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786", "name": "Child", "description": "call::sample_project2.core.model.child.Child", "created_at": "2026-03-02T13:48:04.318589Z", "updated_at": "2026-03-02T13:48:04.318593Z", "qname": "CallSchema/74062b63-0b55-40f5-a107-060e7aa546c2::ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "target_function": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "children_by_type": {"call_children": ["CallSchema/398eb955-92b5-4c98-9e37-6863a7cf277b"], "call_group": []}, "children": [{"id": "CallSchema/398eb955-92b5-4c98-9e37-6863a7cf277b", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:48:04.318610Z", "updated_at": "2026-03-02T13:48:04.318611Z", "qname": "CallSchema/eeaa4f33-6b10-4c87-8e36-150d5a002786::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "ClassSchema/db9ce2c7-4a05-4b99-8dbb-0b65024446d0", "name": "Child", "description": "Class Child", "created_at": "2026-03-02T13:26:10.443291Z", "updated_at": "2026-03-02T13:26:10.443291Z", "qname": "sample_project2.core.model.child.Child", "code_position": {"line_no": 6, "col_offset": 0, "end_line_no": 28, "end_col_offset": 0}, "documents": [], "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "FunctionSchema/2b10cd92-9cb3-40ed-828f-b446e99bfc90", "FunctionSchema/31705d6d-c950-41f5-96b5-4d2f01690c78", "FunctionSchema/fc638f7e-f2a8-4fbc-8ee8-037cbb8f35c2"], "code_element_group": []}, "children": [], "base_classes": ["sample_project2.core.model.parent.GrandParent", "sample_project2.core.model.child.Child", "sample_project2.core.model.parent.Uncle", "sample_project2.core.model.parent.Parent", "builtins.object"], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/1a5b7f13-911d-450a-ad84-3ffe6b577edd", "name": "create_child", "description": "Function create_child", "created_at": "2026-03-02T13:26:10.428977Z", "updated_at": "2026-03-02T13:26:10.428977Z", "qname": "sample_project2.core.utils.helper.create_child", "code_position": {"line_no": 9, "col_offset": 0, "end_line_no": 16, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "FileSchema/bfeb3280-f24b-4556-ad17-d23e05102f12", "name": "__init__", "description": "File __init__", "created_at": "2026-03-02T13:26:09.906428Z", "updated_at": "2026-03-02T13:26:09.906432Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/__init__.py", "qname": "sample_project2.__init__", "documents": [], "theme_config": null, "hash": "e91a9e76a195d5a82f1b3f1abc9cb9729e1ac72de1ab898e5ecd37a0df9ca0ba", "children_by_type": {"class_children": [], "function_children": [], "code_element_group": [], "call_children": [], "call_group": []}, "children": [], "node_type": "file"}, {"id": "FileSchema/cb66b194-9ab0-4d9c-a126-66477c033786", "name": "hello", "description": "File hello", "created_at": "2026-03-02T13:26:09.907042Z", "updated_at": "2026-03-02T13:26:09.907042Z", "path": "/Users/yared/Documents/Programing/ide/playground/parser/sample_project2/hello.py", "qname": "sample_project2.hello", "documents": [], "theme_config": null, "hash": "746e294781781eee487d13a8ae6a73545e93c86b0eeea0b63a8497ff344a030c", "children_by_type": {"class_children": [], "function_children": ["FunctionSchema/f6c92d63-9951-4ddd-953e-755dfdc174f2"], "code_element_group": [], "call_children": [], "call_group": []}, "children": [{"id": "FunctionSchema/f6c92d63-9951-4ddd-953e-755dfdc174f2", "name": "runn", "description": "Function runn", "created_at": "2026-03-02T13:26:10.464700Z", "updated_at": "2026-03-02T13:26:10.464701Z", "qname": "sample_project2.hello.runn", "code_position": {"line_no": 4, "col_offset": 0, "end_line_no": 7, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}], "node_type": "file"}, {"id": "CallSchema/b7b895bd-8b2f-4e44-b05d-6100a9a695a7", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.529767Z", "updated_at": "2026-03-02T13:26:12.529768Z", "qname": "CallSchema/723f0443-2b2d-4747-84bf-1e442c0b3279::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/d5c05556-2c9b-4326-af6a-7f5cce7827b1", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:12.642137Z", "updated_at": "2026-03-02T13:26:12.642137Z", "qname": "CallSchema/5fbf36be-473b-4908-86dc-ef2a149f7e7a::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/5f446258-19a6-4b82-99e5-b2e3e2ba2580", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:33.926408Z", "updated_at": "2026-03-02T13:26:33.926409Z", "qname": "CallSchema/2d60eff6-5131-4e1d-8682-ce4cb0b1822f::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/c88620b5-2933-48cb-94b1-8791b41b2244", "name": "__init__", "description": "call::core.model.child.Child.__init__", "created_at": "2026-03-02T13:26:33.958871Z", "updated_at": "2026-03-02T13:26:33.958871Z", "qname": "CallSchema/c7a257ab-4625-415c-a1b1-5b5e55bd72ac::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/005eea45-e496-4593-8d1f-c445997a391e", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:29:43.791330Z", "updated_at": "2026-03-02T13:29:43.791331Z", "qname": "CallSchema/1366431e-7ec4-4118-9062-e98abd17a8f2::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/99ecf862-f66f-455e-b3b2-a08d52744a15", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:29:43.820557Z", "updated_at": "2026-03-02T13:29:43.820558Z", "qname": "CallSchema/2c271965-fdba-4981-866b-38a3d5149a7e::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/69e51916-6574-4100-92c1-f3addda5ae15", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:35:45.815735Z", "updated_at": "2026-03-02T13:35:45.815736Z", "qname": "CallSchema/2054d62e-90be-4dd6-a2bf-55f7bdee4e7b::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/fdca1add-d35d-4d05-a832-3c179ec08c27", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:35:45.860600Z", "updated_at": "2026-03-02T13:35:45.860604Z", "qname": "CallSchema/9ce6620d-a759-4d3b-8488-88236fbeffbd::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/bb61ff1e-d6d6-47a8-9c47-6931ec49683c", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:40:32.132279Z", "updated_at": "2026-03-02T13:40:32.132281Z", "qname": "CallSchema/6b4447de-bc24-4c4a-a24f-91e8fb4828fb::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/fbde7964-0d78-476d-b504-df3afb8cb5da", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:40:32.108887Z", "updated_at": "2026-03-02T13:40:32.108887Z", "qname": "CallSchema/41155d11-02c5-48c9-a412-8ad9d6dddd42::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/3813285e-f473-41b1-81a6-760ddc72a912", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:43:24.922822Z", "updated_at": "2026-03-02T13:43:24.922822Z", "qname": "CallSchema/f6149e92-cc6f-4f1b-82e1-3d37653328c8::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/d762eb1b-74da-44f6-9749-f38305313cfb", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:43:24.946721Z", "updated_at": "2026-03-02T13:43:24.946722Z", "qname": "CallSchema/ca3e33ea-3966-45e9-9364-513b5f8750fd::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/1a3c1ef4-e944-4ff8-82cc-36fac4d49a94", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:46:03.848060Z", "updated_at": "2026-03-02T13:46:03.848060Z", "qname": "CallSchema/2e431f06-2c91-4920-b1e0-674f965cd423::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}, {"id": "CallSchema/ca9f853f-0393-4ef3-b67e-46002012f6bb", "name": "__init__", "description": "call::sample_project2.core.model.child.Child.__init__", "created_at": "2026-03-02T13:46:03.810823Z", "updated_at": "2026-03-02T13:46:03.810824Z", "qname": "CallSchema/63fef3ab-ebb2-440b-8fcd-21a46cf69f44::FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "target_function": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "children_by_type": {"call_children": [], "call_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "call", "target": {"id": "FunctionSchema/f9b3e29d-ce9e-49ea-9599-ae4ce349f326", "name": "__init__", "description": "Function __init__", "created_at": "2026-03-02T13:26:10.443298Z", "updated_at": "2026-03-02T13:26:10.443298Z", "qname": "sample_project2.core.model.child.Child.__init__", "code_position": {"line_no": 10, "col_offset": 4, "end_line_no": 14, "end_col_offset": 0}, "children_by_type": {"class_children": [], "function_children": [], "code_element_group": []}, "children": [], "documents": [], "theme_config": null, "node_type": "function"}}] \ No newline at end of file From 039070bd41091fc6afec6bb8ed973d08ac908cd1 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 19:00:17 +0300 Subject: [PATCH 18/49] workflows improved --- .../app/agent/workflows/description_gen.py | 4 +- .../app/agent/workflows/documentation_gen.py | 67 ++++++++++--------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index ec65771c..696d1fb4 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -244,7 +244,9 @@ async def _flush_node_updates( if hasattr(node, "documents"): for document_id in set(getattr(node, "documents") or set()): queries.append( - WQ().add_triple(node_id, "documents", document_id) + WQ().opt( + WQ().add_triple(node_id, "documents", document_id) + ) ) if queries: diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py index 6c119c25..39136910 100644 --- a/src/backend/app/agent/workflows/documentation_gen.py +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -3,6 +3,8 @@ from app.agent.models.task_status import TaskStatus from app.core.model.schemas import DocumentSchema from datetime import datetime, timezone +from terminusdb_client.woqlquery.woql_query import Doc +from app.db.async_terminus_client import WOQLQuery as WQ class DocumentationGeneratorWorkflow(DescriptionGeneratorWorkflow): @@ -179,51 +181,52 @@ async def _flush_documentation_batch( except Exception: existing_docs = {} - documents_to_upsert: list[DocumentSchema] = [] - node_updates: dict[str, object] = {} + document_queries = [] + node_link_queries = [] for node_id, content in documentation_values.items(): doc_id = self._documentation_doc_id(node_id) existing_doc = existing_docs.get(doc_id, {}) created_at = existing_doc.get("created_at", now) - documents_to_upsert.append( - DocumentSchema( - _id=doc_id, - name=f"workflow_doc:{node_id}", - description="Generated by documentation workflow.", - data=content, - created_at=created_at, - updated_at=now, - ) + document_schema = DocumentSchema( + _id=doc_id, + name=f"workflow_doc:{node_id}", + description="Generated by documentation workflow.", + data=content, + created_at=created_at, + updated_at=now, + ) + document_raw = document_schema._obj_to_dict()[0] + document_queries.append( + WQ().insert_document(Doc(document_raw)), ) tree_node = processed_nodes.get(node_id) if tree_node is None: continue - current_docs = getattr(tree_node, "documents", None) - if isinstance(current_docs, set): - docs_set = set(current_docs) - elif isinstance(current_docs, list): - docs_set = set(current_docs) - elif current_docs: - docs_set = {str(current_docs)} - else: - docs_set = set() - docs_set.add(doc_id) - node_updates[node_id] = tree_node.model_copy( - update={"documents": docs_set} + tree_node_id = getattr(tree_node, "id", None) + if not tree_node_id: + continue + node_link_queries.append( + WQ().opt(WQ().add_triple(tree_node_id, "documents", doc_id)) ) - await client.update_document( - documents_to_upsert, - commit_msg=( - f"Workflow: upsert {len(documents_to_upsert)} " - "generated documents" - ), - ) - if node_updates: - await self._flush_node_updates(node_updates=node_updates) + if document_queries: + await client.query( + WQ().woql_and(*document_queries), + commit_msg=( + f"Workflow: upsert {len(document_queries)} " + "generated documents" + ), + ) + if node_link_queries: + await client.query( + WQ().woql_and(*node_link_queries), + commit_msg=( + f"Workflow: link {len(node_link_queries)} documents to nodes" + ), + ) return doc_ids From 428f7124f1e0c43439d4fc9b8b0220f68200c0af Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 19:25:10 +0300 Subject: [PATCH 19/49] test imporved --- .../app/agent/workflows/description_gen.py | 8 ++-- .../app/agent/workflows/documentation_gen.py | 19 ++------- .../tests/unit/service/agent/conftest.py | 41 +++++++++++++++++++ .../agent/sample_project/core/model/child.py | 8 ++++ .../agent/sample_project/core/model/parent.py | 10 +++++ .../agent/sample_project/core/utils/helper.py | 5 +++ .../unit/service/agent/sample_project/main.py | 11 +++++ .../agent/test_generation_workflows.py | 1 - .../features/Navbar/components/Navbar.tsx | 4 +- 9 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 src/backend/tests/unit/service/agent/conftest.py create mode 100644 src/backend/tests/unit/service/agent/sample_project/core/model/child.py create mode 100644 src/backend/tests/unit/service/agent/sample_project/core/model/parent.py create mode 100644 src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py create mode 100644 src/backend/tests/unit/service/agent/sample_project/main.py rename src/backend/tests/{e2e => unit/service}/agent/test_generation_workflows.py (98%) diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index 696d1fb4..9df27971 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -133,12 +133,10 @@ def _child_values( ) -> list[str]: values: list[str] = [] for child in getattr(tree_node, "children", []) or []: - child_id = getattr(child, "id", None) - if not child_id: + child_description = getattr(child, "description", None) + if not child_description: continue - child_value = generated_values.get(child_id) - if child_value: - values.append(child_value) + values.append(child_description) return values def _tree_node_to_prompt_doc(self, tree_node: Any) -> dict: diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py index 39136910..38e3e7a9 100644 --- a/src/backend/app/agent/workflows/documentation_gen.py +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -29,13 +29,6 @@ async def run( "Generating descriptions before documentation..." ) - description_result = await super().run( - node_id=node_id, - direction=direction, - max_depth=max_depth, - task_status=None, - ) - if self.graph is None: raise ValueError( "GraphTraversal is required for documentation workflow." @@ -55,9 +48,6 @@ async def run( "upserted_document_ids": [], } - description_values: dict[str, str] = description_result.get( - "description_results", {} - ) documentation_values: dict[str, str] = {} processed_nodes: dict[str, object] = {} @@ -78,15 +68,12 @@ async def run( ) child_descriptions = self._child_values( tree_node=tree_node, - generated_values=description_values, + generated_values=tree_node.description, ) prompt = self._build_documentation_prompt( node_doc=node_doc, - node_description=description_values.get( - current_node_id, - node_doc.get("description", ""), - ), + node_description=tree_node.description, child_documentations=child_documentations, child_descriptions=child_descriptions, ) @@ -110,7 +97,7 @@ async def run( return { "processed": len(documentation_values), "direction": direction, - "description_results": description_values, + "documentation_results": documentation_values, "upserted_document_ids": upserted_doc_ids, } diff --git a/src/backend/tests/unit/service/agent/conftest.py b/src/backend/tests/unit/service/agent/conftest.py new file mode 100644 index 00000000..13905cf6 --- /dev/null +++ b/src/backend/tests/unit/service/agent/conftest.py @@ -0,0 +1,41 @@ +import pytest +import shutil +from pathlib import Path +from app.db.context import RequestDbContext, ProjectUoW +from app.core.services.project_service import ProjectService +from app.core.parser.graph_builder.orchestrator import GraphBuilderOrchestrator +import pytest_asyncio + + +@pytest.fixture +def sample_project_path(tmp_path): + """Returns the path to a temporary copy of the sample project directory for E2E tests.""" + source_path = Path(__file__).parent / "sample_project" + project_path = tmp_path / "sample_project" + shutil.copytree(source_path, project_path) + return str(project_path) + + +@pytest_asyncio.fixture +async def built_sample_project(sample_project_path, terminusdb_client): + """Creates a project and runs GraphBuilder to populate structure (no API).""" + ctx = RequestDbContext() + project_uow = ProjectUoW(terminusdb_client, None, ctx) + project_service = ProjectService(project_uow) + print(f"Creating sample project at: {terminusdb_client.db}") + project_node = await project_service.create( + "sample_project", + "A sample project for E2E tests", + sample_project_path, + ) + + project_uow = ProjectUoW(terminusdb_client, project_node, ctx) + orchestrator = GraphBuilderOrchestrator( + project_node=project_node, + uow=project_uow, + ignore_file_name=".gitignore", + ) + await orchestrator.resync() + yield project_node, project_uow + + await project_service.delete(project_node.id) diff --git a/src/backend/tests/unit/service/agent/sample_project/core/model/child.py b/src/backend/tests/unit/service/agent/sample_project/core/model/child.py new file mode 100644 index 00000000..12165bf7 --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/model/child.py @@ -0,0 +1,8 @@ +from .parent import Parent + +class Child(Parent): + """ ID: e278aa7a-d358-4a4c-aa8e-d67668aefa96 """ + + def __init__(self, name: str): + """ ID: 709db811-a10e-441b-8159-dc8427f60cdd """ + super().__init__(name) \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py b/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py new file mode 100644 index 00000000..5b683a8c --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/model/parent.py @@ -0,0 +1,10 @@ +class Parent: + """ ID: eafcb450-3ccf-4fd7-98e7-10c0abb7f429 """ + + def __init__(self, name: str): + """ ID: fbea723d-0b37-4320-82b7-f5086aafb9c8 """ + self.name = name + + def get_name(self): + """ ID: 86f5a508-7ccc-4009-b62a-6a3989800e93 """ + return self.name \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py b/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py new file mode 100644 index 00000000..7955e73e --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/core/utils/helper.py @@ -0,0 +1,5 @@ +from ..model.child import Child + +def create_child(): + """ ID: e9887730-19a8-45da-b40c-c63368d69571 """ + return Child() \ No newline at end of file diff --git a/src/backend/tests/unit/service/agent/sample_project/main.py b/src/backend/tests/unit/service/agent/sample_project/main.py new file mode 100644 index 00000000..3c860f50 --- /dev/null +++ b/src/backend/tests/unit/service/agent/sample_project/main.py @@ -0,0 +1,11 @@ +from .core.utils.helper import create_child + + +def main(): + """ ID: 64df7ee4-1a99-47ac-8134-0b1d6dffaef6 """ + child = create_child() + print(child.get_name()) + + +if __name__ == '__main__': + main() diff --git a/src/backend/tests/e2e/agent/test_generation_workflows.py b/src/backend/tests/unit/service/agent/test_generation_workflows.py similarity index 98% rename from src/backend/tests/e2e/agent/test_generation_workflows.py rename to src/backend/tests/unit/service/agent/test_generation_workflows.py index 6b5d325b..e2c6d92f 100644 --- a/src/backend/tests/e2e/agent/test_generation_workflows.py +++ b/src/backend/tests/unit/service/agent/test_generation_workflows.py @@ -88,7 +88,6 @@ async def test_documentation_workflow_creates_documents_and_links( repos = uow.get_project_repos() raw_main_doc = await repos.client.get_document(main_node.id) - assert raw_main_doc["description"].startswith("DESC::") expected_doc_id = _doc_id_for_node(main_node.id) assert expected_doc_id in set(raw_main_doc.get("documents", [])) diff --git a/src/frontend/src/features/Dashboard/features/Navbar/components/Navbar.tsx b/src/frontend/src/features/Dashboard/features/Navbar/components/Navbar.tsx index 46667042..2f806ef7 100644 --- a/src/frontend/src/features/Dashboard/features/Navbar/components/Navbar.tsx +++ b/src/frontend/src/features/Dashboard/features/Navbar/components/Navbar.tsx @@ -9,7 +9,7 @@ import { } from "@/components/ui/menubar"; import { ProgressIndicator } from "./ProgressIndicator"; import HistoryButton from "../../Versioning/components/HistoryButton"; -// import { AgentToggleButton } from "../../Agent/components/AgentToggleButton"; +import { AgentToggleButton } from "../../Agent/components/AgentToggleButton"; interface NavbarProps { projectId?: string; @@ -45,7 +45,7 @@ const Navbar = ({ projectId }: NavbarProps) => {
- {/* */} +
From 4cf8ed81da702fb26ca95aab50e095569b147818 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 21:59:43 +0300 Subject: [PATCH 20/49] repo and schema added for conversation --- src/backend/app/agent/models/conversation.py | 32 +- src/backend/app/agent/models/task.py | 119 ++++ .../app/core/model/conversation_nodes.py | 148 +++++ .../app/core/model/schemas/__init__.py | 15 + .../core/model/schemas/conversation_schema.py | 189 ++++++ src/backend/app/core/repository/__init__.py | 2 + .../app/core/repository/conversation_repo.py | 563 ++++++++++++++++++ 7 files changed, 1039 insertions(+), 29 deletions(-) create mode 100644 src/backend/app/agent/models/task.py create mode 100644 src/backend/app/core/model/conversation_nodes.py create mode 100644 src/backend/app/core/model/schemas/conversation_schema.py create mode 100644 src/backend/app/core/repository/conversation_repo.py diff --git a/src/backend/app/agent/models/conversation.py b/src/backend/app/agent/models/conversation.py index 4efac936..7f70cea5 100644 --- a/src/backend/app/agent/models/conversation.py +++ b/src/backend/app/agent/models/conversation.py @@ -3,6 +3,8 @@ from datetime import datetime from enum import Enum +from app.agent.models.task import SubTask, TaskState + class MessageRole(str, Enum): USER = "user" @@ -31,35 +33,6 @@ class EventPart(BaseModel): payload: dict = Field(default_factory=dict) -class SubTaskState(str, Enum): - PENDING = "pending" # ○ not started - RUNNING = "running" # ● in progress - COMPLETED = "completed" # ✓ done - FAILED = "failed" # ✗ error - SKIPPED = "skipped" # — skipped - - -class SubTask(BaseModel): - """One step in a task's timeline.""" - id: str - name: str # e.g. "parse_imports.py" - description: str = "" - state: SubTaskState = SubTaskState.PENDING - started_at: Optional[datetime] = None - finished_at: Optional[datetime] = None - error: Optional[str] = None - # TerminusDB node IDs modified by this step - touched_node_ids: list[str] = Field(default_factory=list) - - -class TaskState(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - class TaskPart(BaseModel): """ A task embedded inside a conversation message. @@ -89,6 +62,7 @@ class ConversationMessage(BaseModel): id: str role: MessageRole parts: list[MessagePart] + sequence: int = 0 created_at: datetime = Field(default_factory=datetime.utcnow) token_count: Optional[int] = None # tokens used by this message model: Optional[str] = None # which LLM generated this diff --git a/src/backend/app/agent/models/task.py b/src/backend/app/agent/models/task.py new file mode 100644 index 00000000..344652da --- /dev/null +++ b/src/backend/app/agent/models/task.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from datetime import datetime +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from app.core.model.conversation_nodes import TaskNode + + +class SubTaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +class TaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class SubTask(BaseModel): + """One persisted workflow step under a task.""" + + id: str = "" + name: str + description: str = "" + state: SubTaskState = SubTaskState.PENDING + sequence: int = 0 + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + touched_node_ids: list[str] = Field(default_factory=list) + + +class Task(BaseModel): + """Standalone task document (not the inline TaskPart in a message).""" + + id: str = "" + name: str = "" + description: str = "" + conversation_id: str = "" + message_id: str = "" + state: TaskState = TaskState.PENDING + progress: float = 0.0 + progress_message: str = "" + workflow_name: Optional[str] = None + workflow_params: Optional[dict[str, Any]] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + result: Optional[Any] = None + sub_task_count: int = 0 + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + def to_task_node(self, task_id: str) -> TaskNode: + return TaskNode( + id=task_id, + name=self.name or "Task", + description=self.description, + conversation_id=self.conversation_id, + message_id=self.message_id, + state=self.state.value, + progress=self.progress, + progress_message=self.progress_message, + workflow_name=self.workflow_name, + workflow_params_json=json.dumps(self.workflow_params) + if self.workflow_params is not None + else None, + started_at=self.started_at, + finished_at=self.finished_at, + error=self.error, + result_json=json.dumps(self.result) if self.result is not None else None, + sub_task_count=self.sub_task_count, + created_at=self.created_at, + updated_at=self.updated_at, + ) + + @staticmethod + def from_task_node(node: TaskNode) -> "Task": + params: Optional[dict[str, Any]] = None + if node.workflow_params_json: + try: + params = json.loads(node.workflow_params_json) + except json.JSONDecodeError: + params = None + result: Any = None + if node.result_json: + try: + result = json.loads(node.result_json) + except json.JSONDecodeError: + result = node.result_json + return Task( + id=node.id, + name=node.name, + description=node.description, + conversation_id=node.conversation_id, + message_id=node.message_id, + state=TaskState(node.state), + progress=node.progress, + progress_message=node.progress_message, + workflow_name=node.workflow_name, + workflow_params=params, + started_at=node.started_at, + finished_at=node.finished_at, + error=node.error, + result=result, + sub_task_count=node.sub_task_count, + created_at=node.created_at, + updated_at=node.updated_at, + ) diff --git a/src/backend/app/core/model/conversation_nodes.py b/src/backend/app/core/model/conversation_nodes.py new file mode 100644 index 00000000..86487bfc --- /dev/null +++ b/src/backend/app/core/model/conversation_nodes.py @@ -0,0 +1,148 @@ +"""Pydantic shapes for agent conversation / message / task documents stored in TerminusDB.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class ConversationNode(BaseModel): + id: str + name: str + description: str = "" + metadata_json: str = "{}" + message_count: int = 0 + has_active_task: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "ConversationNode": + return ConversationNode( + id=raw_dict["@id"], + name=raw_dict["name"], + description=raw_dict.get("description") or "", + metadata_json=raw_dict.get("metadata_json") or "{}", + message_count=int(raw_dict.get("message_count") or 0), + has_active_task=_raw_bool(raw_dict.get("has_active_task")), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class MessageNode(BaseModel): + id: str + conversation_id: str + role: str + parts_json: str + token_count: Optional[int] = None + model_name: Optional[str] = None + sequence: int = 0 + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "MessageNode": + conv = raw_dict.get("conversation") + conv_id = conv if isinstance(conv, str) else (conv or {}).get("@id", "") + return MessageNode( + id=raw_dict["@id"], + conversation_id=conv_id or "", + role=raw_dict["role"], + parts_json=raw_dict.get("parts_json") or "[]", + token_count=raw_dict.get("token_count"), + model_name=raw_dict.get("model_name"), + sequence=int(raw_dict.get("sequence") or 0), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class TaskNode(BaseModel): + id: str + name: str + description: str = "" + conversation_id: str = "" + message_id: str = "" + state: str = "pending" + progress: float = 0.0 + progress_message: str = "" + workflow_name: Optional[str] = None + workflow_params_json: Optional[str] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + result_json: Optional[str] = None + sub_task_count: int = 0 + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "TaskNode": + conv = raw_dict.get("conversation") + msg = raw_dict.get("message") + conv_id = conv if isinstance(conv, str) else (conv or {}).get("@id", "") + msg_id = msg if isinstance(msg, str) else (msg or {}).get("@id", "") + return TaskNode( + id=raw_dict["@id"], + name=raw_dict["name"], + description=raw_dict.get("description") or "", + conversation_id=conv_id or "", + message_id=msg_id or "", + state=raw_dict.get("state") or "pending", + progress=float(raw_dict.get("progress") or 0.0), + progress_message=raw_dict.get("progress_message") or "", + workflow_name=raw_dict.get("workflow_name"), + workflow_params_json=raw_dict.get("workflow_params_json"), + started_at=raw_dict.get("started_at"), + finished_at=raw_dict.get("finished_at"), + error=raw_dict.get("error"), + result_json=raw_dict.get("result_json"), + sub_task_count=int(raw_dict.get("sub_task_count") or 0), + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +class SubTaskNode(BaseModel): + id: str + task_id: str + name: str + description: str = "" + state: str = "pending" + sequence: int = 0 + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + touched_node_ids_json: str = "[]" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + @staticmethod + def from_raw_dict(raw_dict: dict[str, Any]) -> "SubTaskNode": + parent = raw_dict.get("task") + task_id = parent if isinstance(parent, str) else (parent or {}).get("@id", "") + return SubTaskNode( + id=raw_dict["@id"], + task_id=task_id or "", + name=raw_dict["name"], + description=raw_dict.get("description") or "", + state=raw_dict.get("state") or "pending", + sequence=int(raw_dict.get("sequence") or 0), + started_at=raw_dict.get("started_at"), + finished_at=raw_dict.get("finished_at"), + error=raw_dict.get("error"), + touched_node_ids_json=raw_dict.get("touched_node_ids_json") or "[]", + created_at=raw_dict["created_at"], + updated_at=raw_dict["updated_at"], + ) + + +def _raw_bool(value: Any) -> bool: + if value is True or value is False: + return value + if isinstance(value, str): + return value.lower() == "true" + return bool(value) diff --git a/src/backend/app/core/model/schemas/__init__.py b/src/backend/app/core/model/schemas/__init__.py index f73ac127..5230a819 100644 --- a/src/backend/app/core/model/schemas/__init__.py +++ b/src/backend/app/core/model/schemas/__init__.py @@ -22,6 +22,12 @@ CodeContentSchema, ) from .test_schema import TestConfigSchema, TestCaseSchema, TestLinkSchema +from .conversation_schema import ( + ConversationSchema, + MessageSchema, + TaskSchema, + SubTaskSchema, +) __all__ = [ "BaseSchema", @@ -46,6 +52,10 @@ "TestConfigSchema", "TestCaseSchema", "TestLinkSchema", + "ConversationSchema", + "MessageSchema", + "TaskSchema", + "SubTaskSchema", ] @@ -88,6 +98,11 @@ async def ensure_schema( schema_obj.add_obj(TestCaseSchema.__name__, TestCaseSchema) schema_obj.add_obj(TestLinkSchema.__name__, TestLinkSchema) + schema_obj.add_obj(ConversationSchema.__name__, ConversationSchema) + schema_obj.add_obj(MessageSchema.__name__, MessageSchema) + schema_obj.add_obj(TaskSchema.__name__, TaskSchema) + schema_obj.add_obj(SubTaskSchema.__name__, SubTaskSchema) + await schema_obj.commit( client, f"Initialize schema for {title}", diff --git a/src/backend/app/core/model/schemas/conversation_schema.py b/src/backend/app/core/model/schemas/conversation_schema.py new file mode 100644 index 00000000..3ca7d979 --- /dev/null +++ b/src/backend/app/core/model/schemas/conversation_schema.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from app.core.model.conversation_nodes import ( + ConversationNode, + MessageNode, + SubTaskNode, + TaskNode, +) +from .base import BaseSchema, TerminusBase + + +class ConversationSchema(BaseSchema): + """Root conversation document; messages point here via `conversation` edge.""" + + metadata_json: str + message_count: int + has_active_task: bool + + @staticmethod + def from_pydantic(node: ConversationNode) -> "ConversationSchema": + return ConversationSchema( + _id=node.id, + name=node.name, + description=node.description, + metadata_json=node.metadata_json, + message_count=node.message_count, + has_active_task=node.has_active_task, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> ConversationNode: + return ConversationNode( + id=self._id, + name=self.name, + description=self.description, + metadata_json=self.metadata_json or "{}", + message_count=int(self.message_count or 0), + has_active_task=bool(self.has_active_task), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class MessageSchema(TerminusBase): + conversation: "ConversationSchema" + role: str + parts_json: str + token_count: Optional[int] + model_name: Optional[str] + sequence: int + + @staticmethod + def from_pydantic(node: MessageNode) -> "MessageSchema": + return MessageSchema( + _id=node.id, + conversation=node.conversation_id, + role=node.role, + parts_json=node.parts_json, + token_count=node.token_count, + model_name=node.model_name, + sequence=node.sequence, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> MessageNode: + conv = self.conversation + conv_id = conv if isinstance(conv, str) else getattr(conv, "_id", "") + return MessageNode( + id=self._id, + conversation_id=conv_id, + role=self.role, + parts_json=self.parts_json or "[]", + token_count=self.token_count, + model_name=self.model_name, + sequence=int(self.sequence or 0), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class TaskSchema(BaseSchema): + conversation: "ConversationSchema" + message: "MessageSchema" + state: str + progress: float + progress_message: str + workflow_name: Optional[str] + workflow_params_json: Optional[str] + started_at: Optional[datetime] + finished_at: Optional[datetime] + error: Optional[str] + result_json: Optional[str] + sub_task_count: int + + @staticmethod + def from_pydantic(node: TaskNode) -> "TaskSchema": + return TaskSchema( + _id=node.id, + name=node.name, + description=node.description, + conversation=node.conversation_id, + message=node.message_id, + state=node.state, + progress=node.progress, + progress_message=node.progress_message, + workflow_name=node.workflow_name, + workflow_params_json=node.workflow_params_json, + started_at=node.started_at, + finished_at=node.finished_at, + error=node.error, + result_json=node.result_json, + sub_task_count=node.sub_task_count, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> TaskNode: + conv = self.conversation + msg = self.message + return TaskNode( + id=self._id, + name=self.name, + description=self.description, + conversation_id=conv if isinstance(conv, str) else getattr(conv, "_id", ""), + message_id=msg if isinstance(msg, str) else getattr(msg, "_id", ""), + state=self.state, + progress=float(self.progress or 0.0), + progress_message=self.progress_message or "", + workflow_name=self.workflow_name, + workflow_params_json=self.workflow_params_json, + started_at=self.started_at, + finished_at=self.finished_at, + error=self.error, + result_json=self.result_json, + sub_task_count=int(self.sub_task_count or 0), + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class SubTaskSchema(TerminusBase): + task: "TaskSchema" + name: str + description: str + state: str + sequence: int + started_at: Optional[datetime] + finished_at: Optional[datetime] + error: Optional[str] + touched_node_ids_json: str + + @staticmethod + def from_pydantic(node: SubTaskNode) -> "SubTaskSchema": + return SubTaskSchema( + _id=node.id, + task=node.task_id, + name=node.name, + description=node.description, + state=node.state, + sequence=node.sequence, + started_at=node.started_at, + finished_at=node.finished_at, + error=node.error, + touched_node_ids_json=node.touched_node_ids_json, + created_at=node.created_at, + updated_at=node.updated_at, + ) + + def to_pydantic(self) -> SubTaskNode: + parent = self.task + return SubTaskNode( + id=self._id, + task_id=parent if isinstance(parent, str) else getattr(parent, "_id", ""), + name=self.name, + description=self.description, + state=self.state, + sequence=int(self.sequence or 0), + started_at=self.started_at, + finished_at=self.finished_at, + error=self.error, + touched_node_ids_json=self.touched_node_ids_json or "[]", + created_at=self.created_at, + updated_at=self.updated_at, + ) diff --git a/src/backend/app/core/repository/__init__.py b/src/backend/app/core/repository/__init__.py index 83b320c2..fc0401ee 100644 --- a/src/backend/app/core/repository/__init__.py +++ b/src/backend/app/core/repository/__init__.py @@ -15,6 +15,7 @@ from .code_elements.code_element_repo import CodeElementRepo from .code_elements.test_repo import TestRepo from .code_elements.play_ground_repo import PlayGroundRepo +from .conversation_repo import ConversationRepo class Repositories: @@ -37,3 +38,4 @@ def __init__(self, client: AsyncClient): self.document_repo = DocumentRepo(client) self.test_repo = TestRepo(client) self.play_ground_repo = PlayGroundRepo(client) + self.conversation_repo = ConversationRepo(client) diff --git a/src/backend/app/core/repository/conversation_repo.py b/src/backend/app/core/repository/conversation_repo.py new file mode 100644 index 00000000..bbadd82b --- /dev/null +++ b/src/backend/app/core/repository/conversation_repo.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from pydantic import TypeAdapter + +from app.agent.models.conversation import ( + Conversation, + ConversationMessage, + ConversationSummary, + MessagePart, + MessageRole, +) +from app.agent.models.task import SubTask, SubTaskState, Task, TaskState +from app.core.model.conversation_nodes import ( + ConversationNode, + MessageNode, + SubTaskNode, + TaskNode, +) +from app.core.model.schemas.conversation_schema import ( + ConversationSchema, + MessageSchema, + SubTaskSchema, + TaskSchema, +) +from app.db.async_terminus_client import AsyncClient +from app.db.async_terminus_client import WOQLQuery as WQ + +_MESSAGE_PARTS_JSON = TypeAdapter(list[MessagePart]) + +_TERMINAL_TASK_STATES = frozenset( + {TaskState.COMPLETED.value, TaskState.FAILED.value, TaskState.CANCELLED.value} +) + + +def _new_doc_id(class_name: str) -> str: + return f"{class_name}/{uuid.uuid4()}" + + +class ConversationRepo: + """TerminusDB persistence for conversations, messages, tasks, and subtasks.""" + + def __init__(self, client: AsyncClient): + self.client = client + + @staticmethod + def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + @staticmethod + def _parts_to_json(parts: list[MessagePart]) -> str: + return _MESSAGE_PARTS_JSON.dump_json(parts).decode("utf-8") + + @staticmethod + def _parts_from_json(parts_json: str) -> list[MessagePart]: + return _MESSAGE_PARTS_JSON.validate_json(parts_json.encode("utf-8")) + + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + now = self._utcnow() + conv_id = _new_doc_id("ConversationSchema") + node = ConversationNode( + id=conv_id, + name=title, + description=description, + metadata_json=json.dumps(metadata or {}), + message_count=0, + has_active_task=False, + created_at=now, + updated_at=now, + ) + try: + await self.client.insert_document( + ConversationSchema.from_pydantic(node), + commit_msg=f"Creating conversation {title!r}", + ) + except Exception as exc: + print(exc) + return None + return conv_id + + async def get_conversation(self, conversation_id: str) -> Conversation | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + messages = await self.get_messages(conversation_id, cursor=0, limit=10_000) + meta: dict[str, Any] = {} + try: + meta = json.loads(conv.metadata_json or "{}") + except json.JSONDecodeError: + meta = {} + return Conversation( + id=conv.id, + title=conv.name, + description=conv.description, + created_at=conv.created_at, + updated_at=conv.updated_at, + message_count=conv.message_count, + has_active_task=conv.has_active_task, + messages=messages, + metadata=meta, + ) + + async def _get_conversation_node(self, conversation_id: str) -> ConversationNode | None: + try: + raw = await self.client.get_document(conversation_id) + except Exception as exc: + print(exc) + return None + if not raw or "ConversationSchema" not in str(raw.get("@type", "")): + return None + return ConversationNode.from_raw_dict(raw) + + async def list_conversations( + self, + limit: int = 50, + cursor: str | None = None, + ) -> list[ConversationSummary]: + try: + items_raw = await self.client.get_all_documents(doc_type="ConversationSchema") + except Exception as exc: + print(exc) + return [] + nodes = [ConversationNode.from_raw_dict(r) for r in items_raw] + nodes.sort(key=lambda n: n.updated_at, reverse=True) + if cursor: + idx = next((i for i, n in enumerate(nodes) if n.id == cursor), None) + if idx is not None: + nodes = nodes[idx + 1 :] + cap = max(1, limit) + return [ + ConversationSummary( + id=n.id, + title=n.name, + description=n.description, + created_at=n.created_at, + updated_at=n.updated_at, + message_count=n.message_count, + has_active_task=n.has_active_task, + ) + for n in nodes[:cap] + ] + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = self._utcnow() + seq = conv.message_count + msg_id = message.id or _new_doc_id("MessageSchema") + role_val = message.role.value if isinstance(message.role, MessageRole) else str( + message.role + ) + msg_node = MessageNode( + id=msg_id, + conversation_id=conversation_id, + role=role_val, + parts_json=self._parts_to_json(message.parts), + token_count=message.token_count, + model_name=message.model, + sequence=seq, + created_at=message.created_at or now, + updated_at=now, + ) + try: + await self.client.insert_document( + MessageSchema.from_pydantic(msg_node), + commit_msg=f"Message in {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.message_count = seq + 1 + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Bump message_count {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return msg_id + + async def get_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:msg", "conversation", conversation_id), + WQ().triple("v:msg", "rdf:type", "@schema:MessageSchema"), + WQ().triple("v:msg", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:msg_doc").woql_and( + ordered, + WQ().read_document("v:msg", "v:msg_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[ConversationMessage] = [] + for row in result.get("bindings", []): + raw = row.get("msg_doc") + if not raw: + continue + node = MessageNode.from_raw_dict(raw) + parts = self._parts_from_json(node.parts_json) + out.append( + ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) + ) + return out + + async def get_message(self, message_id: str) -> ConversationMessage | None: + try: + raw = await self.client.get_document(message_id) + except Exception as exc: + print(exc) + return None + if not raw or "MessageSchema" not in (raw.get("@type") or ""): + return None + node = MessageNode.from_raw_dict(raw) + parts = self._parts_from_json(node.parts_json) + return ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) + + async def create_task( + self, + conversation_id: str, + message_id: str, + task: Task, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = self._utcnow() + task_id = task.id or _new_doc_id("TaskSchema") + task.conversation_id = conversation_id + task.message_id = message_id + task.created_at = task.created_at or now + task.updated_at = now + node = task.to_task_node(task_id) + try: + await self.client.insert_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Task for conversation {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.has_active_task = True + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Mark active task {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return task_id + + async def update_task(self, task_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = TaskNode.from_raw_dict(raw) + conv_id = node.conversation_id + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + st = fields["state"] + node.state = st.value if isinstance(st, TaskState) else str(st) + if "progress" in fields: + node.progress = float(fields["progress"]) + if "progress_message" in fields: + node.progress_message = fields["progress_message"] + if "workflow_name" in fields: + node.workflow_name = fields["workflow_name"] + if "workflow_params" in fields: + wp = fields["workflow_params"] + node.workflow_params_json = json.dumps(wp) if wp is not None else None + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "result" in fields: + r = fields["result"] + node.result_json = json.dumps(r) if r is not None else None + if "sub_task_count" in fields: + node.sub_task_count = int(fields["sub_task_count"]) + + node.updated_at = self._utcnow() + + try: + await self.client.update_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Update task {task_id}", + ) + except Exception as exc: + print(exc) + return False + + if node.state in _TERMINAL_TASK_STATES and conv_id: + await self._maybe_clear_active_task(conv_id) + return True + + async def _maybe_clear_active_task(self, conversation_id: str) -> None: + conv = await self._get_conversation_node(conversation_id) + if conv is None or not conv.has_active_task: + return + try: + open_tasks = await self._query_tasks_for_conversation( + conversation_id, + states=list( + { + TaskState.PENDING.value, + TaskState.RUNNING.value, + } + ), + ) + except Exception as exc: + print(exc) + return + if open_tasks: + return + conv.has_active_task = False + conv.updated_at = self._utcnow() + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Clear active task flag {conversation_id}", + ) + except Exception as exc: + print(exc) + + async def _query_tasks_for_conversation( + self, + conversation_id: str, + states: list[str], + ) -> list[TaskNode]: + if not states: + return [] + query = ( + WQ() + .select("v:task_doc") + .woql_and( + WQ().triple("v:task", "conversation", conversation_id), + WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), + WQ().triple("v:task", "state", "v:state"), + WQ().member("v:state", [WQ().string(s) for s in states]), + WQ().read_document("v:task", "v:task_doc"), + ) + ) + try: + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + out: list[TaskNode] = [] + for row in result.get("bindings", []): + raw = row.get("task_doc") + if raw: + out.append(TaskNode.from_raw_dict(raw)) + return out + + async def get_task(self, task_id: str) -> Task | None: + try: + raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return None + if not raw: + return None + node = TaskNode.from_raw_dict(raw) + return Task.from_task_node(node) + + async def append_subtask(self, task_id: str, subtask: SubTask) -> str | None: + try: + task_raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return None + if not task_raw: + return None + task_node = TaskNode.from_raw_dict(task_raw) + now = self._utcnow() + seq = task_node.sub_task_count + sub_id = subtask.id or _new_doc_id("SubTaskSchema") + st = subtask.state + state_val = st.value if isinstance(st, SubTaskState) else str(st) + sub_node = SubTaskNode( + id=sub_id, + task_id=task_id, + name=subtask.name, + description=subtask.description, + state=state_val, + sequence=seq, + started_at=subtask.started_at, + finished_at=subtask.finished_at, + error=subtask.error, + touched_node_ids_json=json.dumps(subtask.touched_node_ids), + created_at=now, + updated_at=now, + ) + + try: + await self.client.insert_document( + SubTaskSchema.from_pydantic(sub_node), + commit_msg=f"Subtask on {task_id}", + ) + except Exception as exc: + print(exc) + return None + + task_node.sub_task_count = seq + 1 + task_node.updated_at = now + try: + await self.client.update_document( + TaskSchema.from_pydantic(task_node), + commit_msg=f"Bump sub_task_count {task_id}", + ) + except Exception as exc: + print(exc) + return None + return sub_id + + async def update_subtask(self, subtask_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(subtask_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = SubTaskNode.from_raw_dict(raw) + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + st = fields["state"] + node.state = st.value if hasattr(st, "value") else str(st) + if "sequence" in fields: + node.sequence = int(fields["sequence"]) + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "touched_node_ids" in fields: + node.touched_node_ids_json = json.dumps(fields["touched_node_ids"]) + + node.updated_at = self._utcnow() + + try: + await self.client.update_document( + SubTaskSchema.from_pydantic(node), + commit_msg=f"Update subtask {subtask_id}", + ) + except Exception as exc: + print(exc) + return False + return True + + async def get_subtasks( + self, + task_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[SubTask]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:st", "task", task_id), + WQ().triple("v:st", "rdf:type", "@schema:SubTaskSchema"), + WQ().triple("v:st", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:st_doc").woql_and( + ordered, + WQ().read_document("v:st", "v:st_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[SubTask] = [] + for row in result.get("bindings", []): + raw = row.get("st_doc") + if not raw: + continue + n = SubTaskNode.from_raw_dict(raw) + touched: list[str] = [] + try: + touched = json.loads(n.touched_node_ids_json or "[]") + except json.JSONDecodeError: + touched = [] + out.append( + SubTask( + id=n.id, + name=n.name, + description=n.description, + state=SubTaskState(n.state), + sequence=n.sequence, + started_at=n.started_at, + finished_at=n.finished_at, + error=n.error, + touched_node_ids=touched, + ) + ) + return out From 1d65d3bbab1a6ef32f48d6fa98036d449e7f2e60 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 22:09:14 +0300 Subject: [PATCH 21/49] split in to smaller files --- src/backend/app/core/repository/__init__.py | 2 +- .../core/repository/conversation/__init__.py | 3 + .../core/repository/conversation/_common.py | 33 + .../repository/conversation/conversations.py | 113 ++++ .../core/repository/conversation/messages.py | 137 +++++ .../app/core/repository/conversation/repo.py | 22 + .../core/repository/conversation/subtasks.py | 166 ++++++ .../app/core/repository/conversation/tasks.py | 187 ++++++ .../app/core/repository/conversation_repo.py | 563 ------------------ 9 files changed, 662 insertions(+), 564 deletions(-) create mode 100644 src/backend/app/core/repository/conversation/__init__.py create mode 100644 src/backend/app/core/repository/conversation/_common.py create mode 100644 src/backend/app/core/repository/conversation/conversations.py create mode 100644 src/backend/app/core/repository/conversation/messages.py create mode 100644 src/backend/app/core/repository/conversation/repo.py create mode 100644 src/backend/app/core/repository/conversation/subtasks.py create mode 100644 src/backend/app/core/repository/conversation/tasks.py delete mode 100644 src/backend/app/core/repository/conversation_repo.py diff --git a/src/backend/app/core/repository/__init__.py b/src/backend/app/core/repository/__init__.py index fc0401ee..87bed048 100644 --- a/src/backend/app/core/repository/__init__.py +++ b/src/backend/app/core/repository/__init__.py @@ -15,7 +15,7 @@ from .code_elements.code_element_repo import CodeElementRepo from .code_elements.test_repo import TestRepo from .code_elements.play_ground_repo import PlayGroundRepo -from .conversation_repo import ConversationRepo +from .conversation import ConversationRepo class Repositories: diff --git a/src/backend/app/core/repository/conversation/__init__.py b/src/backend/app/core/repository/conversation/__init__.py new file mode 100644 index 00000000..c5730c3b --- /dev/null +++ b/src/backend/app/core/repository/conversation/__init__.py @@ -0,0 +1,3 @@ +from .repo import ConversationRepo + +__all__ = ["ConversationRepo"] diff --git a/src/backend/app/core/repository/conversation/_common.py b/src/backend/app/core/repository/conversation/_common.py new file mode 100644 index 00000000..97f98790 --- /dev/null +++ b/src/backend/app/core/repository/conversation/_common.py @@ -0,0 +1,33 @@ +"""Shared helpers and constants for conversation persistence.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from pydantic import TypeAdapter + +from app.agent.models.conversation import MessagePart +from app.agent.models.task import TaskState + +MESSAGE_PARTS_ADAPTER = TypeAdapter(list[MessagePart]) + +TERMINAL_TASK_STATES = frozenset( + {TaskState.COMPLETED.value, TaskState.FAILED.value, TaskState.CANCELLED.value} +) + + +def new_doc_id(class_name: str) -> str: + return f"{class_name}/{uuid.uuid4()}" + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def parts_to_json(parts: list[MessagePart]) -> str: + return MESSAGE_PARTS_ADAPTER.dump_json(parts).decode("utf-8") + + +def parts_from_json(parts_json: str) -> list[MessagePart]: + return MESSAGE_PARTS_ADAPTER.validate_json(parts_json.encode("utf-8")) diff --git a/src/backend/app/core/repository/conversation/conversations.py b/src/backend/app/core/repository/conversation/conversations.py new file mode 100644 index 00000000..b16cce70 --- /dev/null +++ b/src/backend/app/core/repository/conversation/conversations.py @@ -0,0 +1,113 @@ +"""Conversation document CRUD and listing.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from app.agent.models.conversation import Conversation, ConversationSummary +from app.core.model.conversation_nodes import ConversationNode +from app.core.model.schemas.conversation_schema import ConversationSchema + +from ._common import new_doc_id, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class ConversationsMixin: + client: "AsyncClient" + + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str | None: + now = utcnow() + conv_id = new_doc_id("ConversationSchema") + node = ConversationNode( + id=conv_id, + name=title, + description=description, + metadata_json=json.dumps(metadata or {}), + message_count=0, + has_active_task=False, + created_at=now, + updated_at=now, + ) + try: + await self.client.insert_document( + ConversationSchema.from_pydantic(node), + commit_msg=f"Creating conversation {title!r}", + ) + except Exception as exc: + print(exc) + return None + return conv_id + + async def get_conversation(self, conversation_id: str) -> Conversation | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + messages = await self.get_messages( + conversation_id, cursor=0, limit=10_000 + ) + meta: dict[str, Any] = {} + try: + meta = json.loads(conv.metadata_json or "{}") + except json.JSONDecodeError: + meta = {} + return Conversation( + id=conv.id, + title=conv.name, + description=conv.description, + created_at=conv.created_at, + updated_at=conv.updated_at, + message_count=conv.message_count, + has_active_task=conv.has_active_task, + messages=messages, + metadata=meta, + ) + + async def _get_conversation_node( + self, conversation_id: str + ) -> ConversationNode | None: + try: + raw = await self.client.get_document(conversation_id) + except Exception as exc: + print(exc) + return None + if not raw or "ConversationSchema" not in str(raw.get("@type", "")): + return None + return ConversationNode.from_raw_dict(raw) + + async def list_conversations( + self, + limit: int = 50, + cursor: str | None = None, + ) -> list[ConversationSummary]: + try: + items_raw = await self.client.get_all_documents(doc_type="ConversationSchema") + except Exception as exc: + print(exc) + return [] + nodes = [ConversationNode.from_raw_dict(r) for r in items_raw] + nodes.sort(key=lambda n: n.updated_at, reverse=True) + if cursor: + idx = next((i for i, n in enumerate(nodes) if n.id == cursor), None) + if idx is not None: + nodes = nodes[idx + 1:] + cap = max(1, limit) + return [ + ConversationSummary( + id=n.id, + title=n.name, + description=n.description, + created_at=n.created_at, + updated_at=n.updated_at, + message_count=n.message_count, + has_active_task=n.has_active_task, + ) + for n in nodes[:cap] + ] diff --git a/src/backend/app/core/repository/conversation/messages.py b/src/backend/app/core/repository/conversation/messages.py new file mode 100644 index 00000000..0c777ae4 --- /dev/null +++ b/src/backend/app/core/repository/conversation/messages.py @@ -0,0 +1,137 @@ +"""Message documents: append and paginated read.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.agent.models.conversation import ( + ConversationMessage, + MessageRole, +) +from app.core.model.conversation_nodes import MessageNode +from app.core.model.schemas.conversation_schema import ( + ConversationSchema, + MessageSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import new_doc_id, parts_from_json, parts_to_json, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class MessagesMixin: + client: "AsyncClient" + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = utcnow() + seq = conv.message_count + msg_id = message.id or new_doc_id("MessageSchema") + if isinstance(message.role, MessageRole): + role_val = message.role.value + else: + role_val = str(message.role) + msg_node = MessageNode( + id=msg_id, + conversation_id=conversation_id, + role=role_val, + parts_json=parts_to_json(message.parts), + token_count=message.token_count, + model_name=message.model, + sequence=seq, + created_at=message.created_at or now, + updated_at=now, + ) + try: + await self.client.insert_document( + MessageSchema.from_pydantic(msg_node), + commit_msg=f"Message in {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.message_count = seq + 1 + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Bump message_count {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return msg_id + + async def get_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:msg", "conversation", conversation_id), + WQ().triple("v:msg", "rdf:type", "@schema:MessageSchema"), + WQ().triple("v:msg", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:msg_doc").woql_and( + ordered, + WQ().read_document("v:msg", "v:msg_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[ConversationMessage] = [] + for row in result.get("bindings", []): + raw = row.get("msg_doc") + if not raw: + continue + node = MessageNode.from_raw_dict(raw) + parts = parts_from_json(node.parts_json) + out.append( + ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) + ) + return out + + async def get_message(self, message_id: str) -> ConversationMessage | None: + try: + raw = await self.client.get_document(message_id) + except Exception as exc: + print(exc) + return None + if not raw or "MessageSchema" not in (raw.get("@type") or ""): + return None + node = MessageNode.from_raw_dict(raw) + parts = parts_from_json(node.parts_json) + return ConversationMessage( + id=node.id, + role=MessageRole(node.role), + parts=parts, + sequence=node.sequence, + created_at=node.created_at, + token_count=node.token_count, + model=node.model_name, + ) diff --git a/src/backend/app/core/repository/conversation/repo.py b/src/backend/app/core/repository/conversation/repo.py new file mode 100644 index 00000000..8b6f5d37 --- /dev/null +++ b/src/backend/app/core/repository/conversation/repo.py @@ -0,0 +1,22 @@ +"""Composed repository for conversations, messages, tasks, and subtasks.""" + +from __future__ import annotations + +from app.db.async_terminus_client import AsyncClient + +from .conversations import ConversationsMixin +from .messages import MessagesMixin +from .subtasks import SubtasksMixin +from .tasks import TasksMixin + + +class ConversationRepo( + ConversationsMixin, + MessagesMixin, + TasksMixin, + SubtasksMixin, +): + """TerminusDB: conversations, messages, tasks, and subtasks.""" + + def __init__(self, client: AsyncClient): + self.client = client diff --git a/src/backend/app/core/repository/conversation/subtasks.py b/src/backend/app/core/repository/conversation/subtasks.py new file mode 100644 index 00000000..d8760cfc --- /dev/null +++ b/src/backend/app/core/repository/conversation/subtasks.py @@ -0,0 +1,166 @@ +"""SubTask documents under a task.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from app.agent.models.task import SubTask, SubTaskState +from app.core.model.conversation_nodes import SubTaskNode, TaskNode +from app.core.model.schemas.conversation_schema import ( + SubTaskSchema, + TaskSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import new_doc_id, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class SubtasksMixin: + client: "AsyncClient" + + async def append_subtask( + self, task_id: str, subtask: SubTask + ) -> str | None: + try: + task_raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return None + if not task_raw: + return None + task_node = TaskNode.from_raw_dict(task_raw) + now = utcnow() + seq = task_node.sub_task_count + sub_id = subtask.id or new_doc_id("SubTaskSchema") + st = subtask.state + state_val = st.value if isinstance(st, SubTaskState) else str(st) + sub_node = SubTaskNode( + id=sub_id, + task_id=task_id, + name=subtask.name, + description=subtask.description, + state=state_val, + sequence=seq, + started_at=subtask.started_at, + finished_at=subtask.finished_at, + error=subtask.error, + touched_node_ids_json=json.dumps(subtask.touched_node_ids), + created_at=now, + updated_at=now, + ) + + try: + await self.client.insert_document( + SubTaskSchema.from_pydantic(sub_node), + commit_msg=f"Subtask on {task_id}", + ) + except Exception as exc: + print(exc) + return None + + task_node.sub_task_count = seq + 1 + task_node.updated_at = now + try: + await self.client.update_document( + TaskSchema.from_pydantic(task_node), + commit_msg=f"Bump sub_task_count {task_id}", + ) + except Exception as exc: + print(exc) + return None + return sub_id + + async def update_subtask(self, subtask_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(subtask_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = SubTaskNode.from_raw_dict(raw) + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + st = fields["state"] + node.state = st.value if hasattr(st, "value") else str(st) + if "sequence" in fields: + node.sequence = int(fields["sequence"]) + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "touched_node_ids" in fields: + node.touched_node_ids_json = json.dumps(fields["touched_node_ids"]) + + node.updated_at = utcnow() + + try: + await self.client.update_document( + SubTaskSchema.from_pydantic(node), + commit_msg=f"Update subtask {subtask_id}", + ) + except Exception as exc: + print(exc) + return False + return True + + async def get_subtasks( + self, + task_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[SubTask]: + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + try: + filtered = WQ().woql_and( + WQ().triple("v:st", "task", task_id), + WQ().triple("v:st", "rdf:type", "@schema:SubTaskSchema"), + WQ().triple("v:st", "sequence", "v:seq"), + WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), + ) + ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) + query = WQ().select("v:st_doc").woql_and( + ordered, + WQ().read_document("v:st", "v:st_doc"), + ) + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + + out: list[SubTask] = [] + for row in result.get("bindings", []): + raw = row.get("st_doc") + if not raw: + continue + n = SubTaskNode.from_raw_dict(raw) + touched: list[str] = [] + try: + touched = json.loads(n.touched_node_ids_json or "[]") + except json.JSONDecodeError: + touched = [] + out.append( + SubTask( + id=n.id, + name=n.name, + description=n.description, + state=SubTaskState(n.state), + sequence=n.sequence, + started_at=n.started_at, + finished_at=n.finished_at, + error=n.error, + touched_node_ids=touched, + ) + ) + return out diff --git a/src/backend/app/core/repository/conversation/tasks.py b/src/backend/app/core/repository/conversation/tasks.py new file mode 100644 index 00000000..2be11cc1 --- /dev/null +++ b/src/backend/app/core/repository/conversation/tasks.py @@ -0,0 +1,187 @@ +"""Task documents and conversation active-task flag.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from app.agent.models.task import Task, TaskState +from app.core.model.conversation_nodes import TaskNode +from app.core.model.schemas.conversation_schema import ( + ConversationSchema, + TaskSchema, +) +from app.db.async_terminus_client import WOQLQuery as WQ + +from ._common import TERMINAL_TASK_STATES, new_doc_id, utcnow + +if TYPE_CHECKING: + from app.db.async_terminus_client import AsyncClient + + +class TasksMixin: + client: "AsyncClient" + + async def create_task( + self, + conversation_id: str, + message_id: str, + task: Task, + ) -> str | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + now = utcnow() + task_id = task.id or new_doc_id("TaskSchema") + task.conversation_id = conversation_id + task.message_id = message_id + task.created_at = task.created_at or now + task.updated_at = now + node = task.to_task_node(task_id) + try: + await self.client.insert_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Task for conversation {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + + conv.has_active_task = True + conv.updated_at = now + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Mark active task {conversation_id}", + ) + except Exception as exc: + print(exc) + return None + return task_id + + async def update_task(self, task_id: str, **fields: Any) -> bool: + try: + raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return False + if not raw: + return False + node = TaskNode.from_raw_dict(raw) + conv_id = node.conversation_id + + if "name" in fields: + node.name = fields["name"] + if "description" in fields: + node.description = fields["description"] + if "state" in fields: + st = fields["state"] + if isinstance(st, TaskState): + node.state = st.value + else: + node.state = str(st) + if "progress" in fields: + node.progress = float(fields["progress"]) + if "progress_message" in fields: + node.progress_message = fields["progress_message"] + if "workflow_name" in fields: + node.workflow_name = fields["workflow_name"] + if "workflow_params" in fields: + wp = fields["workflow_params"] + node.workflow_params_json = json.dumps(wp) if wp is not None else None + if "started_at" in fields: + node.started_at = fields["started_at"] + if "finished_at" in fields: + node.finished_at = fields["finished_at"] + if "error" in fields: + node.error = fields["error"] + if "result" in fields: + r = fields["result"] + node.result_json = json.dumps(r) if r is not None else None + if "sub_task_count" in fields: + node.sub_task_count = int(fields["sub_task_count"]) + + node.updated_at = utcnow() + + try: + await self.client.update_document( + TaskSchema.from_pydantic(node), + commit_msg=f"Update task {task_id}", + ) + except Exception as exc: + print(exc) + return False + + if node.state in TERMINAL_TASK_STATES and conv_id: + await self._maybe_clear_active_task(conv_id) + return True + + async def _maybe_clear_active_task(self, conversation_id: str) -> None: + conv = await self._get_conversation_node(conversation_id) + if conv is None or not conv.has_active_task: + return + try: + open_tasks = await self._query_tasks_for_conversation( + conversation_id, + states=list( + { + TaskState.PENDING.value, + TaskState.RUNNING.value, + } + ), + ) + except Exception as exc: + print(exc) + return + if open_tasks: + return + conv.has_active_task = False + conv.updated_at = utcnow() + try: + await self.client.update_document( + ConversationSchema.from_pydantic(conv), + commit_msg=f"Clear active task flag {conversation_id}", + ) + except Exception as exc: + print(exc) + + async def _query_tasks_for_conversation( + self, + conversation_id: str, + states: list[str], + ) -> list[TaskNode]: + if not states: + return [] + query = ( + WQ() + .select("v:task_doc") + .woql_and( + WQ().triple("v:task", "conversation", conversation_id), + WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), + WQ().triple("v:task", "state", "v:state"), + WQ().member("v:state", [WQ().string(s) for s in states]), + WQ().read_document("v:task", "v:task_doc"), + ) + ) + try: + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + out: list[TaskNode] = [] + for row in result.get("bindings", []): + raw = row.get("task_doc") + if raw: + out.append(TaskNode.from_raw_dict(raw)) + return out + + async def get_task(self, task_id: str) -> Task | None: + try: + raw = await self.client.get_document(task_id) + except Exception as exc: + print(exc) + return None + if not raw: + return None + node = TaskNode.from_raw_dict(raw) + return Task.from_task_node(node) diff --git a/src/backend/app/core/repository/conversation_repo.py b/src/backend/app/core/repository/conversation_repo.py deleted file mode 100644 index bbadd82b..00000000 --- a/src/backend/app/core/repository/conversation_repo.py +++ /dev/null @@ -1,563 +0,0 @@ -from __future__ import annotations - -import json -import uuid -from datetime import datetime, timezone -from typing import Any, Optional - -from pydantic import TypeAdapter - -from app.agent.models.conversation import ( - Conversation, - ConversationMessage, - ConversationSummary, - MessagePart, - MessageRole, -) -from app.agent.models.task import SubTask, SubTaskState, Task, TaskState -from app.core.model.conversation_nodes import ( - ConversationNode, - MessageNode, - SubTaskNode, - TaskNode, -) -from app.core.model.schemas.conversation_schema import ( - ConversationSchema, - MessageSchema, - SubTaskSchema, - TaskSchema, -) -from app.db.async_terminus_client import AsyncClient -from app.db.async_terminus_client import WOQLQuery as WQ - -_MESSAGE_PARTS_JSON = TypeAdapter(list[MessagePart]) - -_TERMINAL_TASK_STATES = frozenset( - {TaskState.COMPLETED.value, TaskState.FAILED.value, TaskState.CANCELLED.value} -) - - -def _new_doc_id(class_name: str) -> str: - return f"{class_name}/{uuid.uuid4()}" - - -class ConversationRepo: - """TerminusDB persistence for conversations, messages, tasks, and subtasks.""" - - def __init__(self, client: AsyncClient): - self.client = client - - @staticmethod - def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - @staticmethod - def _parts_to_json(parts: list[MessagePart]) -> str: - return _MESSAGE_PARTS_JSON.dump_json(parts).decode("utf-8") - - @staticmethod - def _parts_from_json(parts_json: str) -> list[MessagePart]: - return _MESSAGE_PARTS_JSON.validate_json(parts_json.encode("utf-8")) - - async def create_conversation( - self, - title: str, - description: str = "", - metadata: dict | None = None, - ) -> str: - now = self._utcnow() - conv_id = _new_doc_id("ConversationSchema") - node = ConversationNode( - id=conv_id, - name=title, - description=description, - metadata_json=json.dumps(metadata or {}), - message_count=0, - has_active_task=False, - created_at=now, - updated_at=now, - ) - try: - await self.client.insert_document( - ConversationSchema.from_pydantic(node), - commit_msg=f"Creating conversation {title!r}", - ) - except Exception as exc: - print(exc) - return None - return conv_id - - async def get_conversation(self, conversation_id: str) -> Conversation | None: - conv = await self._get_conversation_node(conversation_id) - if conv is None: - return None - messages = await self.get_messages(conversation_id, cursor=0, limit=10_000) - meta: dict[str, Any] = {} - try: - meta = json.loads(conv.metadata_json or "{}") - except json.JSONDecodeError: - meta = {} - return Conversation( - id=conv.id, - title=conv.name, - description=conv.description, - created_at=conv.created_at, - updated_at=conv.updated_at, - message_count=conv.message_count, - has_active_task=conv.has_active_task, - messages=messages, - metadata=meta, - ) - - async def _get_conversation_node(self, conversation_id: str) -> ConversationNode | None: - try: - raw = await self.client.get_document(conversation_id) - except Exception as exc: - print(exc) - return None - if not raw or "ConversationSchema" not in str(raw.get("@type", "")): - return None - return ConversationNode.from_raw_dict(raw) - - async def list_conversations( - self, - limit: int = 50, - cursor: str | None = None, - ) -> list[ConversationSummary]: - try: - items_raw = await self.client.get_all_documents(doc_type="ConversationSchema") - except Exception as exc: - print(exc) - return [] - nodes = [ConversationNode.from_raw_dict(r) for r in items_raw] - nodes.sort(key=lambda n: n.updated_at, reverse=True) - if cursor: - idx = next((i for i, n in enumerate(nodes) if n.id == cursor), None) - if idx is not None: - nodes = nodes[idx + 1 :] - cap = max(1, limit) - return [ - ConversationSummary( - id=n.id, - title=n.name, - description=n.description, - created_at=n.created_at, - updated_at=n.updated_at, - message_count=n.message_count, - has_active_task=n.has_active_task, - ) - for n in nodes[:cap] - ] - - async def add_message( - self, - conversation_id: str, - message: ConversationMessage, - ) -> str | None: - conv = await self._get_conversation_node(conversation_id) - if conv is None: - return None - now = self._utcnow() - seq = conv.message_count - msg_id = message.id or _new_doc_id("MessageSchema") - role_val = message.role.value if isinstance(message.role, MessageRole) else str( - message.role - ) - msg_node = MessageNode( - id=msg_id, - conversation_id=conversation_id, - role=role_val, - parts_json=self._parts_to_json(message.parts), - token_count=message.token_count, - model_name=message.model, - sequence=seq, - created_at=message.created_at or now, - updated_at=now, - ) - try: - await self.client.insert_document( - MessageSchema.from_pydantic(msg_node), - commit_msg=f"Message in {conversation_id}", - ) - except Exception as exc: - print(exc) - return None - - conv.message_count = seq + 1 - conv.updated_at = now - try: - await self.client.update_document( - ConversationSchema.from_pydantic(conv), - commit_msg=f"Bump message_count {conversation_id}", - ) - except Exception as exc: - print(exc) - return None - return msg_id - - async def get_messages( - self, - conversation_id: str, - cursor: int = 0, - limit: int = 50, - ) -> list[ConversationMessage]: - cursor = max(0, int(cursor)) - cap = max(1, int(limit)) - try: - filtered = WQ().woql_and( - WQ().triple("v:msg", "conversation", conversation_id), - WQ().triple("v:msg", "rdf:type", "@schema:MessageSchema"), - WQ().triple("v:msg", "sequence", "v:seq"), - WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), - ) - ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) - query = WQ().select("v:msg_doc").woql_and( - ordered, - WQ().read_document("v:msg", "v:msg_doc"), - ) - result = await self.client.query(query) - except Exception as exc: - print(exc) - return [] - - out: list[ConversationMessage] = [] - for row in result.get("bindings", []): - raw = row.get("msg_doc") - if not raw: - continue - node = MessageNode.from_raw_dict(raw) - parts = self._parts_from_json(node.parts_json) - out.append( - ConversationMessage( - id=node.id, - role=MessageRole(node.role), - parts=parts, - sequence=node.sequence, - created_at=node.created_at, - token_count=node.token_count, - model=node.model_name, - ) - ) - return out - - async def get_message(self, message_id: str) -> ConversationMessage | None: - try: - raw = await self.client.get_document(message_id) - except Exception as exc: - print(exc) - return None - if not raw or "MessageSchema" not in (raw.get("@type") or ""): - return None - node = MessageNode.from_raw_dict(raw) - parts = self._parts_from_json(node.parts_json) - return ConversationMessage( - id=node.id, - role=MessageRole(node.role), - parts=parts, - sequence=node.sequence, - created_at=node.created_at, - token_count=node.token_count, - model=node.model_name, - ) - - async def create_task( - self, - conversation_id: str, - message_id: str, - task: Task, - ) -> str | None: - conv = await self._get_conversation_node(conversation_id) - if conv is None: - return None - now = self._utcnow() - task_id = task.id or _new_doc_id("TaskSchema") - task.conversation_id = conversation_id - task.message_id = message_id - task.created_at = task.created_at or now - task.updated_at = now - node = task.to_task_node(task_id) - try: - await self.client.insert_document( - TaskSchema.from_pydantic(node), - commit_msg=f"Task for conversation {conversation_id}", - ) - except Exception as exc: - print(exc) - return None - - conv.has_active_task = True - conv.updated_at = now - try: - await self.client.update_document( - ConversationSchema.from_pydantic(conv), - commit_msg=f"Mark active task {conversation_id}", - ) - except Exception as exc: - print(exc) - return None - return task_id - - async def update_task(self, task_id: str, **fields: Any) -> bool: - try: - raw = await self.client.get_document(task_id) - except Exception as exc: - print(exc) - return False - if not raw: - return False - node = TaskNode.from_raw_dict(raw) - conv_id = node.conversation_id - - if "name" in fields: - node.name = fields["name"] - if "description" in fields: - node.description = fields["description"] - if "state" in fields: - st = fields["state"] - node.state = st.value if isinstance(st, TaskState) else str(st) - if "progress" in fields: - node.progress = float(fields["progress"]) - if "progress_message" in fields: - node.progress_message = fields["progress_message"] - if "workflow_name" in fields: - node.workflow_name = fields["workflow_name"] - if "workflow_params" in fields: - wp = fields["workflow_params"] - node.workflow_params_json = json.dumps(wp) if wp is not None else None - if "started_at" in fields: - node.started_at = fields["started_at"] - if "finished_at" in fields: - node.finished_at = fields["finished_at"] - if "error" in fields: - node.error = fields["error"] - if "result" in fields: - r = fields["result"] - node.result_json = json.dumps(r) if r is not None else None - if "sub_task_count" in fields: - node.sub_task_count = int(fields["sub_task_count"]) - - node.updated_at = self._utcnow() - - try: - await self.client.update_document( - TaskSchema.from_pydantic(node), - commit_msg=f"Update task {task_id}", - ) - except Exception as exc: - print(exc) - return False - - if node.state in _TERMINAL_TASK_STATES and conv_id: - await self._maybe_clear_active_task(conv_id) - return True - - async def _maybe_clear_active_task(self, conversation_id: str) -> None: - conv = await self._get_conversation_node(conversation_id) - if conv is None or not conv.has_active_task: - return - try: - open_tasks = await self._query_tasks_for_conversation( - conversation_id, - states=list( - { - TaskState.PENDING.value, - TaskState.RUNNING.value, - } - ), - ) - except Exception as exc: - print(exc) - return - if open_tasks: - return - conv.has_active_task = False - conv.updated_at = self._utcnow() - try: - await self.client.update_document( - ConversationSchema.from_pydantic(conv), - commit_msg=f"Clear active task flag {conversation_id}", - ) - except Exception as exc: - print(exc) - - async def _query_tasks_for_conversation( - self, - conversation_id: str, - states: list[str], - ) -> list[TaskNode]: - if not states: - return [] - query = ( - WQ() - .select("v:task_doc") - .woql_and( - WQ().triple("v:task", "conversation", conversation_id), - WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), - WQ().triple("v:task", "state", "v:state"), - WQ().member("v:state", [WQ().string(s) for s in states]), - WQ().read_document("v:task", "v:task_doc"), - ) - ) - try: - result = await self.client.query(query) - except Exception as exc: - print(exc) - return [] - out: list[TaskNode] = [] - for row in result.get("bindings", []): - raw = row.get("task_doc") - if raw: - out.append(TaskNode.from_raw_dict(raw)) - return out - - async def get_task(self, task_id: str) -> Task | None: - try: - raw = await self.client.get_document(task_id) - except Exception as exc: - print(exc) - return None - if not raw: - return None - node = TaskNode.from_raw_dict(raw) - return Task.from_task_node(node) - - async def append_subtask(self, task_id: str, subtask: SubTask) -> str | None: - try: - task_raw = await self.client.get_document(task_id) - except Exception as exc: - print(exc) - return None - if not task_raw: - return None - task_node = TaskNode.from_raw_dict(task_raw) - now = self._utcnow() - seq = task_node.sub_task_count - sub_id = subtask.id or _new_doc_id("SubTaskSchema") - st = subtask.state - state_val = st.value if isinstance(st, SubTaskState) else str(st) - sub_node = SubTaskNode( - id=sub_id, - task_id=task_id, - name=subtask.name, - description=subtask.description, - state=state_val, - sequence=seq, - started_at=subtask.started_at, - finished_at=subtask.finished_at, - error=subtask.error, - touched_node_ids_json=json.dumps(subtask.touched_node_ids), - created_at=now, - updated_at=now, - ) - - try: - await self.client.insert_document( - SubTaskSchema.from_pydantic(sub_node), - commit_msg=f"Subtask on {task_id}", - ) - except Exception as exc: - print(exc) - return None - - task_node.sub_task_count = seq + 1 - task_node.updated_at = now - try: - await self.client.update_document( - TaskSchema.from_pydantic(task_node), - commit_msg=f"Bump sub_task_count {task_id}", - ) - except Exception as exc: - print(exc) - return None - return sub_id - - async def update_subtask(self, subtask_id: str, **fields: Any) -> bool: - try: - raw = await self.client.get_document(subtask_id) - except Exception as exc: - print(exc) - return False - if not raw: - return False - node = SubTaskNode.from_raw_dict(raw) - - if "name" in fields: - node.name = fields["name"] - if "description" in fields: - node.description = fields["description"] - if "state" in fields: - st = fields["state"] - node.state = st.value if hasattr(st, "value") else str(st) - if "sequence" in fields: - node.sequence = int(fields["sequence"]) - if "started_at" in fields: - node.started_at = fields["started_at"] - if "finished_at" in fields: - node.finished_at = fields["finished_at"] - if "error" in fields: - node.error = fields["error"] - if "touched_node_ids" in fields: - node.touched_node_ids_json = json.dumps(fields["touched_node_ids"]) - - node.updated_at = self._utcnow() - - try: - await self.client.update_document( - SubTaskSchema.from_pydantic(node), - commit_msg=f"Update subtask {subtask_id}", - ) - except Exception as exc: - print(exc) - return False - return True - - async def get_subtasks( - self, - task_id: str, - cursor: int = 0, - limit: int = 50, - ) -> list[SubTask]: - cursor = max(0, int(cursor)) - cap = max(1, int(limit)) - try: - filtered = WQ().woql_and( - WQ().triple("v:st", "task", task_id), - WQ().triple("v:st", "rdf:type", "@schema:SubTaskSchema"), - WQ().triple("v:st", "sequence", "v:seq"), - WQ().greater("v:seq", WQ().literal(cursor - 1, "xsd:integer")), - ) - ordered = WQ().order_by("v:seq", order="asc").limit(cap, filtered) - query = WQ().select("v:st_doc").woql_and( - ordered, - WQ().read_document("v:st", "v:st_doc"), - ) - result = await self.client.query(query) - except Exception as exc: - print(exc) - return [] - - out: list[SubTask] = [] - for row in result.get("bindings", []): - raw = row.get("st_doc") - if not raw: - continue - n = SubTaskNode.from_raw_dict(raw) - touched: list[str] = [] - try: - touched = json.loads(n.touched_node_ids_json or "[]") - except json.JSONDecodeError: - touched = [] - out.append( - SubTask( - id=n.id, - name=n.name, - description=n.description, - state=SubTaskState(n.state), - sequence=n.sequence, - started_at=n.started_at, - finished_at=n.finished_at, - error=n.error, - touched_node_ids=touched, - ) - ) - return out From 9aaa7b6e6df52c3b1b2cda0ebb86902a3aa8f929 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Thu, 19 Mar 2026 23:52:44 +0300 Subject: [PATCH 22/49] conversation logic started --- src/backend/app/agent/config.py | 6 - src/backend/app/agent/conversation_store.py | 121 ++++++ src/backend/app/agent/models/__init__.py | 0 .../app/agent/models/conversation_store.py | 406 ------------------ src/backend/app/agent/models/task.py | 119 ----- src/backend/app/agent/models/task_status.py | 25 -- src/backend/app/agent/runner/executor.py | 37 +- src/backend/app/agent/runner/task_manager.py | 40 +- .../app/agent/workflows/description_gen.py | 4 +- .../app/agent/workflows/documentation_gen.py | 4 +- src/backend/app/api/v1/agent/deps.py | 2 +- .../model/conversation_domain.py} | 55 ++- .../app/core/model/conversation_enums.py | 25 ++ .../app/core/model/conversation_nodes.py | 118 +++-- .../core/model/schemas/conversation_schema.py | 26 +- .../core/repository/conversation/_common.py | 10 +- .../repository/conversation/conversations.py | 25 +- .../core/repository/conversation/messages.py | 47 +- .../core/repository/conversation/subtasks.py | 53 +-- .../app/core/repository/conversation/tasks.py | 49 ++- src/backend/app/main.py | 15 +- 21 files changed, 434 insertions(+), 753 deletions(-) create mode 100644 src/backend/app/agent/conversation_store.py delete mode 100644 src/backend/app/agent/models/__init__.py delete mode 100644 src/backend/app/agent/models/conversation_store.py delete mode 100644 src/backend/app/agent/models/task.py delete mode 100644 src/backend/app/agent/models/task_status.py rename src/backend/app/{agent/models/conversation.py => core/model/conversation_domain.py} (60%) create mode 100644 src/backend/app/core/model/conversation_enums.py diff --git a/src/backend/app/agent/config.py b/src/backend/app/agent/config.py index 5eedc7bc..e5982af0 100644 --- a/src/backend/app/agent/config.py +++ b/src/backend/app/agent/config.py @@ -1,7 +1,5 @@ import os from functools import lru_cache -from typing import Literal - from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -19,10 +17,6 @@ class AgentConfig(BaseSettings): # VectorLink vectorlink_url: str = "http://localhost:8080" - # Conversation store - conversation_store_backend: Literal["memory", "sqlite"] = "memory" - conversation_store_sqlite_path: str = "data/agent_conversations.sqlite3" - model_config = SettingsConfigDict( env_prefix="AGENT_", env_file=os.environ.get("ENV_FILE", ".env"), diff --git a/src/backend/app/agent/conversation_store.py b/src/backend/app/agent/conversation_store.py new file mode 100644 index 00000000..9a0feb85 --- /dev/null +++ b/src/backend/app/agent/conversation_store.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Protocol + +from app.core.model.conversation_domain import ( + Conversation, + ConversationMessage, + ConversationSummary, + TaskPart, +) +from app.core.repository.conversation import ConversationRepo + + +class ConversationStore(Protocol): + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + ... + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> None: + ... + + async def get_conversation( + self, conversation_id: str + ) -> Conversation | None: + ... + + async def list_conversations( + self, limit: int = 50 + ) -> list[ConversationSummary]: + ... + + async def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + ... + + +class TerminusConversationStore: + """Conversation persistence backed by TerminusDB via `ConversationRepo`.""" + + def __init__(self, repo: ConversationRepo): + self._repo = repo + + async def create_conversation( + self, + title: str, + description: str = "", + metadata: dict | None = None, + ) -> str: + cid = await self._repo.create_conversation( + title, description, metadata + ) + if cid is None: + raise RuntimeError( + "Failed to create conversation in TerminusDB" + ) + return cid + + async def add_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> None: + mid = await self._repo.add_message(conversation_id, message) + if mid is None: + raise ValueError( + f"Failed to add message to conversation {conversation_id!r}" + ) + + async def get_conversation( + self, conversation_id: str + ) -> Conversation | None: + return await self._repo.get_conversation(conversation_id) + + async def list_conversations( + self, limit: int = 50 + ) -> list[ConversationSummary]: + return await self._repo.list_conversations(limit=limit) + + async def upsert_task_part( + self, + conversation_id: str, + task_part: TaskPart, + ) -> None: + conv = await self._repo.get_conversation(conversation_id) + if conv is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + for message in reversed(conv.messages): + for idx, part in enumerate(message.parts): + if ( + isinstance(part, TaskPart) + and part.task_id == task_part.task_id + ): + new_parts = list(message.parts) + new_parts[idx] = task_part + updated = message.model_copy(update={"parts": new_parts}) + ok = await self._repo.update_message( + conversation_id, updated + ) + if not ok: + raise ValueError( + f"Failed to update message {message.id} " + f"for task part" + ) + return + + raise ValueError( + f"TaskPart not found for task_id={task_part.task_id} " + f"in conversation={conversation_id}" + ) diff --git a/src/backend/app/agent/models/__init__.py b/src/backend/app/agent/models/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/backend/app/agent/models/conversation_store.py b/src/backend/app/agent/models/conversation_store.py deleted file mode 100644 index a00b806e..00000000 --- a/src/backend/app/agent/models/conversation_store.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations - -import sqlite3 -import threading -import uuid -from datetime import datetime -from pathlib import Path -from typing import Protocol - -from app.agent.models.conversation import ( - Conversation, - ConversationMessage, - ConversationSummary, - TaskPart, -) - - -class ConversationStore(Protocol): - def create_conversation( - self, - title: str, - description: str = "", - metadata: dict | None = None, - ) -> str: - ... - - def add_message( - self, - conversation_id: str, - message: ConversationMessage, - ) -> None: - ... - - def get_conversation(self, conversation_id: str) -> Conversation | None: - ... - - def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: - ... - - def upsert_task_part( - self, - conversation_id: str, - task_part: TaskPart, - ) -> None: - ... - - -class InMemoryConversationStore: - """Simple process-local conversation store.""" - - def __init__(self): - self._lock = threading.Lock() - self._conversations: dict[str, Conversation] = {} - - def create_conversation( - self, - title: str, - description: str = "", - metadata: dict | None = None, - ) -> str: - conversation_id = str(uuid.uuid4()) - now = datetime.utcnow() - conversation = Conversation( - id=conversation_id, - title=title, - description=description, - created_at=now, - updated_at=now, - messages=[], - metadata=metadata or {}, - ) - with self._lock: - self._conversations[conversation_id] = conversation - return conversation_id - - def add_message( - self, - conversation_id: str, - message: ConversationMessage, - ) -> None: - with self._lock: - conversation = self._conversations.get(conversation_id) - if conversation is None: - raise ValueError(f"Conversation not found: {conversation_id}") - conversation.messages.append(message) - conversation.updated_at = datetime.utcnow() - conversation.message_count = len(conversation.messages) - - def get_conversation(self, conversation_id: str) -> Conversation | None: - with self._lock: - conversation = self._conversations.get(conversation_id) - if conversation is None: - return None - return Conversation.model_validate(conversation.model_dump()) - - def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: - with self._lock: - conversations = sorted( - self._conversations.values(), - key=lambda item: item.updated_at, - reverse=True, - ) - sliced = conversations[: max(1, limit)] - return [ - ConversationSummary( - id=item.id, - title=item.title, - description=item.description, - created_at=item.created_at, - updated_at=item.updated_at, - message_count=len(item.messages), - ) - for item in sliced - ] - - def upsert_task_part( - self, - conversation_id: str, - task_part: TaskPart, - ) -> None: - with self._lock: - conversation = self._conversations.get(conversation_id) - if conversation is None: - raise ValueError(f"Conversation not found: {conversation_id}") - - for message in reversed(conversation.messages): - replaced = False - for idx, part in enumerate(message.parts): - if ( - isinstance(part, TaskPart) - and part.task_id == task_part.task_id - ): - message.parts[idx] = task_part - replaced = True - break - if replaced: - conversation.updated_at = datetime.utcnow() - return - - raise ValueError( - f"TaskPart not found for task_id={task_part.task_id} " - f"in conversation={conversation_id}" - ) - - -class SQLiteConversationStore: - """SQLite-backed conversation store with the same interface.""" - - def __init__( - self, - db_path: str, - ): - self.db_path = db_path - self._lock = threading.Lock() - path = Path(db_path) - path.parent.mkdir(parents=True, exist_ok=True) - self._init_schema() - - def _connect(self) -> sqlite3.Connection: - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - return conn - - def _init_schema(self) -> None: - with self._connect() as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS conversations ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - metadata_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS conversation_messages ( - id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL, - message_json TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(conversation_id) REFERENCES conversations(id) - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_messages_conversation - ON conversation_messages(conversation_id, created_at) - """ - ) - conn.commit() - - def create_conversation( - self, - title: str, - description: str = "", - metadata: dict | None = None, - ) -> str: - conversation_id = str(uuid.uuid4()) - now = datetime.utcnow().isoformat() - metadata_json = "{}" - if metadata: - from json import dumps - - metadata_json = dumps(metadata) - - with self._lock: - with self._connect() as conn: - conn.execute( - """ - INSERT INTO conversations ( - id, title, description, metadata_json, created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - conversation_id, - title, - description, - metadata_json, - now, - now, - ), - ) - conn.commit() - return conversation_id - - def add_message( - self, - conversation_id: str, - message: ConversationMessage, - ) -> None: - with self._lock: - with self._connect() as conn: - row = conn.execute( - "SELECT id FROM conversations WHERE id = ?", - (conversation_id,), - ).fetchone() - if row is None: - raise ValueError( - f"Conversation not found: {conversation_id}" - ) - - message_json = message.model_dump_json() - created_at = datetime.utcnow().isoformat() - conn.execute( - """ - INSERT INTO conversation_messages ( - id, conversation_id, message_json, created_at - ) - VALUES (?, ?, ?, ?) - """, - (message.id, conversation_id, message_json, created_at), - ) - conn.execute( - "UPDATE conversations SET updated_at = ? WHERE id = ?", - (created_at, conversation_id), - ) - conn.commit() - - def get_conversation( - self, - conversation_id: str, - ) -> Conversation | None: - from json import loads - - with self._lock: - with self._connect() as conn: - row = conn.execute( - """ - SELECT - id, - title, - description, - metadata_json, - created_at, - updated_at - FROM conversations - WHERE id = ? - """, - (conversation_id,), - ).fetchone() - if row is None: - return None - - message_rows = conn.execute( - """ - SELECT message_json - FROM conversation_messages - WHERE conversation_id = ? - ORDER BY created_at ASC - """, - (conversation_id,), - ).fetchall() - - messages = [ - ConversationMessage.model_validate_json(item["message_json"]) - for item in message_rows - ] - metadata = loads(row["metadata_json"]) if row["metadata_json"] else {} - created_at = datetime.fromisoformat(row["created_at"]) - updated_at = datetime.fromisoformat(row["updated_at"]) - - return Conversation( - id=row["id"], - title=row["title"], - description=row["description"], - created_at=created_at, - updated_at=updated_at, - message_count=len(messages), - messages=messages, - metadata=metadata, - ) - - def list_conversations(self, limit: int = 50) -> list[ConversationSummary]: - with self._lock: - with self._connect() as conn: - rows = conn.execute( - """ - SELECT - c.id, - c.title, - c.description, - c.created_at, - c.updated_at, - ( - SELECT COUNT(1) - FROM conversation_messages m - WHERE m.conversation_id = c.id - ) AS message_count - FROM conversations c - ORDER BY c.updated_at DESC - LIMIT ? - """, - (max(1, limit),), - ).fetchall() - - return [ - ConversationSummary( - id=row["id"], - title=row["title"], - description=row["description"], - created_at=datetime.fromisoformat(row["created_at"]), - updated_at=datetime.fromisoformat(row["updated_at"]), - message_count=row["message_count"], - ) - for row in rows - ] - - def upsert_task_part( - self, - conversation_id: str, - task_part: TaskPart, - ) -> None: - with self._lock: - with self._connect() as conn: - rows = conn.execute( - """ - SELECT id, message_json - FROM conversation_messages - WHERE conversation_id = ? - ORDER BY created_at DESC - """, - (conversation_id,), - ).fetchall() - - for row in rows: - msg = ConversationMessage.model_validate_json( - row["message_json"] - ) - replaced = False - for idx, part in enumerate(msg.parts): - if ( - isinstance(part, TaskPart) - and part.task_id == task_part.task_id - ): - msg.parts[idx] = task_part - replaced = True - break - if not replaced: - continue - - conn.execute( - ( - "UPDATE conversation_messages " - "SET message_json = ? WHERE id = ?" - ), - (msg.model_dump_json(), row["id"]), - ) - conn.execute( - "UPDATE conversations SET updated_at = ? WHERE id = ?", - (datetime.utcnow().isoformat(), conversation_id), - ) - conn.commit() - return - - raise ValueError( - ( - f"TaskPart not found for task_id={task_part.task_id} " - f"in conversation={conversation_id}" - ) - ) diff --git a/src/backend/app/agent/models/task.py b/src/backend/app/agent/models/task.py deleted file mode 100644 index 344652da..00000000 --- a/src/backend/app/agent/models/task.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime -from enum import Enum -from typing import Any, Optional - -from pydantic import BaseModel, Field - -from app.core.model.conversation_nodes import TaskNode - - -class SubTaskState(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - SKIPPED = "skipped" - - -class TaskState(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -class SubTask(BaseModel): - """One persisted workflow step under a task.""" - - id: str = "" - name: str - description: str = "" - state: SubTaskState = SubTaskState.PENDING - sequence: int = 0 - started_at: Optional[datetime] = None - finished_at: Optional[datetime] = None - error: Optional[str] = None - touched_node_ids: list[str] = Field(default_factory=list) - - -class Task(BaseModel): - """Standalone task document (not the inline TaskPart in a message).""" - - id: str = "" - name: str = "" - description: str = "" - conversation_id: str = "" - message_id: str = "" - state: TaskState = TaskState.PENDING - progress: float = 0.0 - progress_message: str = "" - workflow_name: Optional[str] = None - workflow_params: Optional[dict[str, Any]] = None - started_at: Optional[datetime] = None - finished_at: Optional[datetime] = None - error: Optional[str] = None - result: Optional[Any] = None - sub_task_count: int = 0 - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - - def to_task_node(self, task_id: str) -> TaskNode: - return TaskNode( - id=task_id, - name=self.name or "Task", - description=self.description, - conversation_id=self.conversation_id, - message_id=self.message_id, - state=self.state.value, - progress=self.progress, - progress_message=self.progress_message, - workflow_name=self.workflow_name, - workflow_params_json=json.dumps(self.workflow_params) - if self.workflow_params is not None - else None, - started_at=self.started_at, - finished_at=self.finished_at, - error=self.error, - result_json=json.dumps(self.result) if self.result is not None else None, - sub_task_count=self.sub_task_count, - created_at=self.created_at, - updated_at=self.updated_at, - ) - - @staticmethod - def from_task_node(node: TaskNode) -> "Task": - params: Optional[dict[str, Any]] = None - if node.workflow_params_json: - try: - params = json.loads(node.workflow_params_json) - except json.JSONDecodeError: - params = None - result: Any = None - if node.result_json: - try: - result = json.loads(node.result_json) - except json.JSONDecodeError: - result = node.result_json - return Task( - id=node.id, - name=node.name, - description=node.description, - conversation_id=node.conversation_id, - message_id=node.message_id, - state=TaskState(node.state), - progress=node.progress, - progress_message=node.progress_message, - workflow_name=node.workflow_name, - workflow_params=params, - started_at=node.started_at, - finished_at=node.finished_at, - error=node.error, - result=result, - sub_task_count=node.sub_task_count, - created_at=node.created_at, - updated_at=node.updated_at, - ) diff --git a/src/backend/app/agent/models/task_status.py b/src/backend/app/agent/models/task_status.py deleted file mode 100644 index 60ceafed..00000000 --- a/src/backend/app/agent/models/task_status.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime -from enum import Enum -from typing import Optional, Any - - -class TaskState(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -class TaskStatus(BaseModel): - id: str - name: str - state: TaskState - created_at: datetime - started_at: Optional[datetime] = None - finished_at: Optional[datetime] = None - progress: float = 0.0 # 0.0 → 1.0 - progress_message: str = "" - result: Optional[Any] = None - error: Optional[str] = None diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index c38173b8..7dd81dcd 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -4,15 +4,14 @@ from app.agent.runner.task_manager import TaskManager from app.agent.workflows.base import BaseWorkflow -from app.agent.models.conversation import ( +from app.agent.conversation_store import ConversationStore +from app.core.model.conversation_domain import ( ConversationMessage, - MessageRole, TaskPart, - TaskState as ConversationTaskState, TextPart, ) -from app.agent.models.conversation_store import ConversationStore -from app.agent.models.task_status import TaskStatus +from app.core.model.conversation_enums import MessageRole, TaskState as ConversationTaskState +from app.core.model.conversation_nodes import Task from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field @@ -60,18 +59,20 @@ async def run_workflow( # 1. Auto-create conversation if standalone if conversation_id is None: title, description = await self._generate_title(workflow, kwargs) - conversation_id = self.store.create_conversation( - title, description) + conversation_id = await self.store.create_conversation( + title, description + ) + + async def _on_status(status: Task) -> None: + await self._update_task_part( + conversation_id, task_id, status + ) # 2. Submit task and attach a timeline message to the conversation task_id = self.task_manager.submit( name=f"workflow:{workflow.name}", coro_factory=workflow.run, - on_status_update=lambda status: self._update_task_part( - conversation_id, - task_id, - status, - ), + on_status_update=_on_status, **kwargs, ) @@ -81,7 +82,7 @@ async def run_workflow( workflow_name=workflow.name, workflow_params=kwargs, ) - self.store.add_message( + await self.store.add_message( conversation_id, ConversationMessage( id=str(uuid.uuid4()), @@ -91,7 +92,7 @@ async def run_workflow( ) self._task_part_templates[task_id] = task_part # Push initial state after the message has been written. - self._update_task_part( + await self._update_task_part( conversation_id, task_id, self.task_manager.get_status(task_id), @@ -157,12 +158,12 @@ async def _generate_title(self, workflow, params) -> tuple[str, str]: # Never block workflow scheduling on title generation issues. return fallback_title, fallback_description - def _update_task_part( + async def _update_task_part( self, conversation_id: str, task_id: str, - task_status: TaskStatus | None, - ): + task_status: Task | None, + ) -> None: """Update existing TaskPart container for one workflow task.""" if task_status is None: return @@ -180,5 +181,5 @@ def _update_task_part( "finished_at": task_status.finished_at, } ) - self.store.upsert_task_part(conversation_id, updated_part) + await self.store.upsert_task_part(conversation_id, updated_part) self._task_part_templates[task_id] = updated_part diff --git a/src/backend/app/agent/runner/task_manager.py b/src/backend/app/agent/runner/task_manager.py index 74d836b6..a187f836 100644 --- a/src/backend/app/agent/runner/task_manager.py +++ b/src/backend/app/agent/runner/task_manager.py @@ -1,39 +1,49 @@ import asyncio +import inspect +import json import uuid from datetime import datetime -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Union -from app.agent.models.task_status import TaskStatus, TaskState +from app.core.model.conversation_enums import TaskState +from app.core.model.conversation_nodes import Task class TaskManager: def __init__(self): - self._tasks: dict[str, TaskStatus] = {} + self._tasks: dict[str, Task] = {} self._asyncio_tasks: dict[str, asyncio.Task] = {} def submit( self, name: str, coro_factory: Callable[..., Any], - on_status_update: Optional[Callable[[TaskStatus], None]] = None, + on_status_update: Optional[ + Callable[[Task], Union[None, Any]] + ] = None, **kwargs ) -> str: task_id = str(uuid.uuid4()) - status = TaskStatus( + now = datetime.utcnow() + status = Task( id=task_id, name=name, state=TaskState.PENDING, - created_at=datetime.utcnow(), + created_at=now, + updated_at=now, ) self._tasks[task_id] = status update_interval_s = 0.5 - def _emit_status_update(): + + async def _emit_status_update(): if not on_status_update: return try: - on_status_update(status.model_copy(deep=True)) + out = on_status_update(status.model_copy(deep=True)) + if inspect.isawaitable(out): + await out except Exception: # Status propagation should never crash task execution. return @@ -41,13 +51,13 @@ def _emit_status_update(): async def _wrapper(): status.state = TaskState.RUNNING status.started_at = datetime.utcnow() - _emit_status_update() + await _emit_status_update() reporter_task = None if on_status_update: async def _reporter(): while status.state == TaskState.RUNNING: - _emit_status_update() + await _emit_status_update() await asyncio.sleep(update_interval_s) reporter_task = asyncio.create_task(_reporter()) @@ -57,7 +67,9 @@ async def _reporter(): run_kwargs.setdefault("task_status", status) result = await coro_factory(**run_kwargs) status.state = TaskState.COMPLETED - status.result = result + status.result_json = ( + json.dumps(result, default=str) if result is not None else None + ) except asyncio.CancelledError: status.state = TaskState.CANCELLED except Exception as e: @@ -71,14 +83,14 @@ async def _reporter(): await reporter_task except asyncio.CancelledError: pass - _emit_status_update() + await _emit_status_update() loop = asyncio.get_running_loop() atask = loop.create_task(_wrapper()) self._asyncio_tasks[task_id] = atask return task_id - def get_status(self, task_id: str) -> Optional[TaskStatus]: + def get_status(self, task_id: str) -> Optional[Task]: return self._tasks.get(task_id) def cancel(self, task_id: str) -> bool: @@ -88,5 +100,5 @@ def cancel(self, task_id: str) -> bool: return True return False - def list_tasks(self) -> list[TaskStatus]: + def list_tasks(self) -> list[Task]: return list(self._tasks.values()) diff --git a/src/backend/app/agent/workflows/description_gen.py b/src/backend/app/agent/workflows/description_gen.py index 9df27971..de139ad1 100644 --- a/src/backend/app/agent/workflows/description_gen.py +++ b/src/backend/app/agent/workflows/description_gen.py @@ -6,7 +6,7 @@ from app.db.async_terminus_client import WOQLQuery as WQ from app.agent.workflows.base import BaseWorkflow from app.agent.context.graph_traversal import GraphTraversal -from app.agent.models.task_status import TaskStatus +from app.core.model.conversation_nodes import Task class DescriptionGeneratorWorkflow(BaseWorkflow): @@ -23,7 +23,7 @@ async def run( # "up" (leaf -> parent) | "down" (parent -> leaf) direction: str = "down", max_depth: int = 5, - task_status: TaskStatus | None = None, + task_status: Task | None = None, **kwargs, ): if self.graph is None: diff --git a/src/backend/app/agent/workflows/documentation_gen.py b/src/backend/app/agent/workflows/documentation_gen.py index 38e3e7a9..5c919fa7 100644 --- a/src/backend/app/agent/workflows/documentation_gen.py +++ b/src/backend/app/agent/workflows/documentation_gen.py @@ -1,6 +1,6 @@ from app.agent.context.graph_traversal import GraphTraversal from app.agent.workflows.description_gen import DescriptionGeneratorWorkflow -from app.agent.models.task_status import TaskStatus +from app.core.model.conversation_nodes import Task from app.core.model.schemas import DocumentSchema from datetime import datetime, timezone from terminusdb_client.woqlquery.woql_query import Doc @@ -19,7 +19,7 @@ async def run( node_id: str | None = None, direction: str = "down", max_depth: int = 5, - task_status: TaskStatus | None = None, + task_status: Task | None = None, **kwargs, ): # Documentation starts only after description phase finishes. diff --git a/src/backend/app/api/v1/agent/deps.py b/src/backend/app/api/v1/agent/deps.py index 0094ab9d..d908013f 100644 --- a/src/backend/app/api/v1/agent/deps.py +++ b/src/backend/app/api/v1/agent/deps.py @@ -1,7 +1,7 @@ from fastapi import Request from app.agent.runner.executor import AgentExecutor -from app.agent.models.conversation_store import ConversationStore +from app.agent.conversation_store import ConversationStore def get_agent_executor(request: Request) -> AgentExecutor: diff --git a/src/backend/app/agent/models/conversation.py b/src/backend/app/core/model/conversation_domain.py similarity index 60% rename from src/backend/app/agent/models/conversation.py rename to src/backend/app/core/model/conversation_domain.py index 7f70cea5..5ee7fbb1 100644 --- a/src/backend/app/agent/models/conversation.py +++ b/src/backend/app/core/model/conversation_domain.py @@ -1,15 +1,14 @@ -from pydantic import BaseModel, Field -from typing import Optional, Literal, Union -from datetime import datetime -from enum import Enum +"""Conversation UI/message models (parts, messages, aggregates).""" -from app.agent.models.task import SubTask, TaskState +from __future__ import annotations +from datetime import datetime +from typing import Literal, Optional, Union -class MessageRole(str, Enum): - USER = "user" - ASSISTANT = "assistant" - SYSTEM = "system" +from pydantic import BaseModel, Field + +from app.core.model.conversation_enums import MessageRole, TaskState +from app.core.model.conversation_nodes import ConversationNode, SubTask class TextPart(BaseModel): @@ -54,7 +53,6 @@ class TaskPart(BaseModel): workflow_params: Optional[dict] = None -# Union of all part types MessagePart = Union[TextPart, ToolCallPart, EventPart, TaskPart] @@ -64,20 +62,49 @@ class ConversationMessage(BaseModel): parts: list[MessagePart] sequence: int = 0 created_at: datetime = Field(default_factory=datetime.utcnow) - token_count: Optional[int] = None # tokens used by this message - model: Optional[str] = None # which LLM generated this + token_count: Optional[int] = None + model: Optional[str] = None class ConversationSummary(BaseModel): id: str title: str - description: str = "" # LLM-generated summary + description: str = "" created_at: datetime updated_at: datetime message_count: int = 0 - has_active_task: bool = False # quick flag for UI + has_active_task: bool = False + + @classmethod + def from_conversation_node( + cls, node: ConversationNode + ) -> "ConversationSummary": + return cls( + id=node.id, + title=node.name, + description=node.description, + created_at=node.created_at, + updated_at=node.updated_at, + message_count=node.message_count, + has_active_task=node.has_active_task, + ) class Conversation(ConversationSummary): messages: list[ConversationMessage] = Field(default_factory=list) metadata: dict = Field(default_factory=dict) + + @classmethod + def from_conversation_node( + cls, + node: ConversationNode, + *, + messages: list[ConversationMessage], + metadata: dict, + ) -> "Conversation": + summary = ConversationSummary.from_conversation_node(node) + return cls( + **summary.model_dump(), + messages=messages, + metadata=metadata, + ) diff --git a/src/backend/app/core/model/conversation_enums.py b/src/backend/app/core/model/conversation_enums.py new file mode 100644 index 00000000..4798c915 --- /dev/null +++ b/src/backend/app/core/model/conversation_enums.py @@ -0,0 +1,25 @@ +"""Enums shared by conversation, message, and task models.""" + +from enum import Enum + + +class MessageRole(str, Enum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class SubTaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +class TaskState(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/src/backend/app/core/model/conversation_nodes.py b/src/backend/app/core/model/conversation_nodes.py index 86487bfc..f53421cf 100644 --- a/src/backend/app/core/model/conversation_nodes.py +++ b/src/backend/app/core/model/conversation_nodes.py @@ -1,11 +1,37 @@ -"""Pydantic shapes for agent conversation / message / task documents stored in TerminusDB.""" +"""Pydantic documents for agent data persisted in TerminusDB (single source types).""" from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional -from pydantic import BaseModel, Field +import json + +from pydantic import BaseModel, Field, model_validator + +from app.core.model.conversation_enums import SubTaskState, TaskState + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _coerce_task_state(value: Any) -> TaskState: + if isinstance(value, TaskState): + return value + try: + return TaskState(str(value)) + except ValueError: + return TaskState.PENDING + + +def _coerce_subtask_state(value: Any) -> SubTaskState: + if isinstance(value, SubTaskState): + return value + try: + return SubTaskState(str(value)) + except ValueError: + return SubTaskState.PENDING class ConversationNode(BaseModel): @@ -15,8 +41,8 @@ class ConversationNode(BaseModel): metadata_json: str = "{}" message_count: int = 0 has_active_task: bool = False - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) @staticmethod def from_raw_dict(raw_dict: dict[str, Any]) -> "ConversationNode": @@ -40,13 +66,15 @@ class MessageNode(BaseModel): token_count: Optional[int] = None model_name: Optional[str] = None sequence: int = 0 - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) @staticmethod def from_raw_dict(raw_dict: dict[str, Any]) -> "MessageNode": conv = raw_dict.get("conversation") - conv_id = conv if isinstance(conv, str) else (conv or {}).get("@id", "") + conv_id = ( + conv if isinstance(conv, str) else (conv or {}).get("@id", "") + ) return MessageNode( id=raw_dict["@id"], conversation_id=conv_id or "", @@ -60,38 +88,45 @@ def from_raw_dict(raw_dict: dict[str, Any]) -> "MessageNode": ) -class TaskNode(BaseModel): - id: str - name: str +class TaskDocumentBase(BaseModel): + """Shared scalar fields for `Task` (persisted workflow run).""" + + id: str = "" + name: str = "" description: str = "" conversation_id: str = "" message_id: str = "" - state: str = "pending" progress: float = 0.0 progress_message: str = "" workflow_name: Optional[str] = None - workflow_params_json: Optional[str] = None started_at: Optional[datetime] = None finished_at: Optional[datetime] = None error: Optional[str] = None - result_json: Optional[str] = None sub_task_count: int = 0 - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + +class Task(TaskDocumentBase): + state: TaskState = TaskState.PENDING + workflow_params_json: Optional[str] = None + result_json: Optional[str] = None @staticmethod - def from_raw_dict(raw_dict: dict[str, Any]) -> "TaskNode": + def from_raw_dict(raw_dict: dict[str, Any]) -> "Task": conv = raw_dict.get("conversation") msg = raw_dict.get("message") - conv_id = conv if isinstance(conv, str) else (conv or {}).get("@id", "") + conv_id = ( + conv if isinstance(conv, str) else (conv or {}).get("@id", "") + ) msg_id = msg if isinstance(msg, str) else (msg or {}).get("@id", "") - return TaskNode( + return Task( id=raw_dict["@id"], name=raw_dict["name"], description=raw_dict.get("description") or "", conversation_id=conv_id or "", message_id=msg_id or "", - state=raw_dict.get("state") or "pending", + state=_coerce_task_state(raw_dict.get("state")), progress=float(raw_dict.get("progress") or 0.0), progress_message=raw_dict.get("progress_message") or "", workflow_name=raw_dict.get("workflow_name"), @@ -106,35 +141,58 @@ def from_raw_dict(raw_dict: dict[str, Any]) -> "TaskNode": ) -class SubTaskNode(BaseModel): - id: str - task_id: str +class SubTaskDocumentBase(BaseModel): + """Shared scalar fields for `SubTask` (workflow step under a task).""" + + id: str = "" name: str description: str = "" - state: str = "pending" sequence: int = 0 started_at: Optional[datetime] = None finished_at: Optional[datetime] = None error: Optional[str] = None + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field(default_factory=_utc_now) + + +class SubTask(SubTaskDocumentBase): + task_id: str = "" + state: SubTaskState = SubTaskState.PENDING touched_node_ids_json: str = "[]" - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + @model_validator(mode="before") + @classmethod + def _legacy_touched_node_ids(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + if "touched_node_ids" in data: + data = dict(data) + data["touched_node_ids_json"] = json.dumps( + data.pop("touched_node_ids", []) + ) + return data @staticmethod - def from_raw_dict(raw_dict: dict[str, Any]) -> "SubTaskNode": + def from_raw_dict(raw_dict: dict[str, Any]) -> "SubTask": parent = raw_dict.get("task") - task_id = parent if isinstance(parent, str) else (parent or {}).get("@id", "") - return SubTaskNode( + task_id = ( + parent + if isinstance(parent, str) + else (parent or {}).get("@id", "") + ) + return SubTask( id=raw_dict["@id"], task_id=task_id or "", name=raw_dict["name"], description=raw_dict.get("description") or "", - state=raw_dict.get("state") or "pending", + state=_coerce_subtask_state(raw_dict.get("state")), sequence=int(raw_dict.get("sequence") or 0), started_at=raw_dict.get("started_at"), finished_at=raw_dict.get("finished_at"), error=raw_dict.get("error"), - touched_node_ids_json=raw_dict.get("touched_node_ids_json") or "[]", + touched_node_ids_json=( + raw_dict.get("touched_node_ids_json") or "[]" + ), created_at=raw_dict["created_at"], updated_at=raw_dict["updated_at"], ) diff --git a/src/backend/app/core/model/schemas/conversation_schema.py b/src/backend/app/core/model/schemas/conversation_schema.py index 3ca7d979..af820e23 100644 --- a/src/backend/app/core/model/schemas/conversation_schema.py +++ b/src/backend/app/core/model/schemas/conversation_schema.py @@ -6,8 +6,10 @@ from app.core.model.conversation_nodes import ( ConversationNode, MessageNode, - SubTaskNode, - TaskNode, + SubTask, + Task, + _coerce_subtask_state, + _coerce_task_state, ) from .base import BaseSchema, TerminusBase @@ -98,14 +100,14 @@ class TaskSchema(BaseSchema): sub_task_count: int @staticmethod - def from_pydantic(node: TaskNode) -> "TaskSchema": + def from_pydantic(node: Task) -> "TaskSchema": return TaskSchema( _id=node.id, name=node.name, description=node.description, conversation=node.conversation_id, message=node.message_id, - state=node.state, + state=node.state.value, progress=node.progress, progress_message=node.progress_message, workflow_name=node.workflow_name, @@ -119,16 +121,16 @@ def from_pydantic(node: TaskNode) -> "TaskSchema": updated_at=node.updated_at, ) - def to_pydantic(self) -> TaskNode: + def to_pydantic(self) -> Task: conv = self.conversation msg = self.message - return TaskNode( + return Task( id=self._id, name=self.name, description=self.description, conversation_id=conv if isinstance(conv, str) else getattr(conv, "_id", ""), message_id=msg if isinstance(msg, str) else getattr(msg, "_id", ""), - state=self.state, + state=_coerce_task_state(self.state), progress=float(self.progress or 0.0), progress_message=self.progress_message or "", workflow_name=self.workflow_name, @@ -155,13 +157,13 @@ class SubTaskSchema(TerminusBase): touched_node_ids_json: str @staticmethod - def from_pydantic(node: SubTaskNode) -> "SubTaskSchema": + def from_pydantic(node: SubTask) -> "SubTaskSchema": return SubTaskSchema( _id=node.id, task=node.task_id, name=node.name, description=node.description, - state=node.state, + state=node.state.value, sequence=node.sequence, started_at=node.started_at, finished_at=node.finished_at, @@ -171,14 +173,14 @@ def from_pydantic(node: SubTaskNode) -> "SubTaskSchema": updated_at=node.updated_at, ) - def to_pydantic(self) -> SubTaskNode: + def to_pydantic(self) -> SubTask: parent = self.task - return SubTaskNode( + return SubTask( id=self._id, task_id=parent if isinstance(parent, str) else getattr(parent, "_id", ""), name=self.name, description=self.description, - state=self.state, + state=_coerce_subtask_state(self.state), sequence=int(self.sequence or 0), started_at=self.started_at, finished_at=self.finished_at, diff --git a/src/backend/app/core/repository/conversation/_common.py b/src/backend/app/core/repository/conversation/_common.py index 97f98790..187cb491 100644 --- a/src/backend/app/core/repository/conversation/_common.py +++ b/src/backend/app/core/repository/conversation/_common.py @@ -7,13 +7,17 @@ from pydantic import TypeAdapter -from app.agent.models.conversation import MessagePart -from app.agent.models.task import TaskState +from app.core.model.conversation_domain import MessagePart +from app.core.model.conversation_enums import TaskState MESSAGE_PARTS_ADAPTER = TypeAdapter(list[MessagePart]) TERMINAL_TASK_STATES = frozenset( - {TaskState.COMPLETED.value, TaskState.FAILED.value, TaskState.CANCELLED.value} + { + TaskState.COMPLETED, + TaskState.FAILED, + TaskState.CANCELLED, + } ) diff --git a/src/backend/app/core/repository/conversation/conversations.py b/src/backend/app/core/repository/conversation/conversations.py index b16cce70..bd904743 100644 --- a/src/backend/app/core/repository/conversation/conversations.py +++ b/src/backend/app/core/repository/conversation/conversations.py @@ -5,7 +5,7 @@ import json from typing import TYPE_CHECKING, Any -from app.agent.models.conversation import Conversation, ConversationSummary +from app.core.model.conversation_domain import Conversation, ConversationSummary from app.core.model.conversation_nodes import ConversationNode from app.core.model.schemas.conversation_schema import ConversationSchema @@ -58,16 +58,8 @@ async def get_conversation(self, conversation_id: str) -> Conversation | None: meta = json.loads(conv.metadata_json or "{}") except json.JSONDecodeError: meta = {} - return Conversation( - id=conv.id, - title=conv.name, - description=conv.description, - created_at=conv.created_at, - updated_at=conv.updated_at, - message_count=conv.message_count, - has_active_task=conv.has_active_task, - messages=messages, - metadata=meta, + return Conversation.from_conversation_node( + conv, messages=messages, metadata=meta ) async def _get_conversation_node( @@ -100,14 +92,5 @@ async def list_conversations( nodes = nodes[idx + 1:] cap = max(1, limit) return [ - ConversationSummary( - id=n.id, - title=n.name, - description=n.description, - created_at=n.created_at, - updated_at=n.updated_at, - message_count=n.message_count, - has_active_task=n.has_active_task, - ) - for n in nodes[:cap] + ConversationSummary.from_conversation_node(n) for n in nodes[:cap] ] diff --git a/src/backend/app/core/repository/conversation/messages.py b/src/backend/app/core/repository/conversation/messages.py index 0c777ae4..55839078 100644 --- a/src/backend/app/core/repository/conversation/messages.py +++ b/src/backend/app/core/repository/conversation/messages.py @@ -4,10 +4,8 @@ from typing import TYPE_CHECKING -from app.agent.models.conversation import ( - ConversationMessage, - MessageRole, -) +from app.core.model.conversation_domain import ConversationMessage +from app.core.model.conversation_enums import MessageRole from app.core.model.conversation_nodes import MessageNode from app.core.model.schemas.conversation_schema import ( ConversationSchema, @@ -135,3 +133,44 @@ async def get_message(self, message_id: str) -> ConversationMessage | None: token_count=node.token_count, model=node.model_name, ) + + async def update_message( + self, + conversation_id: str, + message: ConversationMessage, + ) -> bool: + try: + raw = await self.client.get_document(message.id) + except Exception as exc: + print(exc) + return False + if not raw or "MessageSchema" not in str(raw.get("@type", "")): + return False + node = MessageNode.from_raw_dict(raw) + if node.conversation_id != conversation_id: + return False + now = utcnow() + if isinstance(message.role, MessageRole): + role_val = message.role.value + else: + role_val = str(message.role) + updated = MessageNode( + id=message.id, + conversation_id=conversation_id, + role=role_val, + parts_json=parts_to_json(message.parts), + token_count=message.token_count, + model_name=message.model, + sequence=node.sequence, + created_at=node.created_at, + updated_at=now, + ) + try: + await self.client.update_document( + MessageSchema.from_pydantic(updated), + commit_msg=f"Update message {message.id}", + ) + except Exception as exc: + print(exc) + return False + return True diff --git a/src/backend/app/core/repository/conversation/subtasks.py b/src/backend/app/core/repository/conversation/subtasks.py index d8760cfc..b27dab4f 100644 --- a/src/backend/app/core/repository/conversation/subtasks.py +++ b/src/backend/app/core/repository/conversation/subtasks.py @@ -5,8 +5,7 @@ import json from typing import TYPE_CHECKING, Any -from app.agent.models.task import SubTask, SubTaskState -from app.core.model.conversation_nodes import SubTaskNode, TaskNode +from app.core.model.conversation_nodes import SubTask, Task, _coerce_subtask_state from app.core.model.schemas.conversation_schema import ( SubTaskSchema, TaskSchema, @@ -32,25 +31,18 @@ async def append_subtask( return None if not task_raw: return None - task_node = TaskNode.from_raw_dict(task_raw) + task_node = Task.from_raw_dict(task_raw) now = utcnow() seq = task_node.sub_task_count sub_id = subtask.id or new_doc_id("SubTaskSchema") - st = subtask.state - state_val = st.value if isinstance(st, SubTaskState) else str(st) - sub_node = SubTaskNode( - id=sub_id, - task_id=task_id, - name=subtask.name, - description=subtask.description, - state=state_val, - sequence=seq, - started_at=subtask.started_at, - finished_at=subtask.finished_at, - error=subtask.error, - touched_node_ids_json=json.dumps(subtask.touched_node_ids), - created_at=now, - updated_at=now, + sub_node = subtask.model_copy( + update={ + "id": sub_id, + "task_id": task_id, + "sequence": seq, + "created_at": now, + "updated_at": now, + } ) try: @@ -82,15 +74,14 @@ async def update_subtask(self, subtask_id: str, **fields: Any) -> bool: return False if not raw: return False - node = SubTaskNode.from_raw_dict(raw) + node = SubTask.from_raw_dict(raw) if "name" in fields: node.name = fields["name"] if "description" in fields: node.description = fields["description"] if "state" in fields: - st = fields["state"] - node.state = st.value if hasattr(st, "value") else str(st) + node.state = _coerce_subtask_state(fields["state"]) if "sequence" in fields: node.sequence = int(fields["sequence"]) if "started_at" in fields: @@ -144,23 +135,5 @@ async def get_subtasks( raw = row.get("st_doc") if not raw: continue - n = SubTaskNode.from_raw_dict(raw) - touched: list[str] = [] - try: - touched = json.loads(n.touched_node_ids_json or "[]") - except json.JSONDecodeError: - touched = [] - out.append( - SubTask( - id=n.id, - name=n.name, - description=n.description, - state=SubTaskState(n.state), - sequence=n.sequence, - started_at=n.started_at, - finished_at=n.finished_at, - error=n.error, - touched_node_ids=touched, - ) - ) + out.append(SubTask.from_raw_dict(raw)) return out diff --git a/src/backend/app/core/repository/conversation/tasks.py b/src/backend/app/core/repository/conversation/tasks.py index 2be11cc1..df7c84ab 100644 --- a/src/backend/app/core/repository/conversation/tasks.py +++ b/src/backend/app/core/repository/conversation/tasks.py @@ -5,8 +5,8 @@ import json from typing import TYPE_CHECKING, Any -from app.agent.models.task import Task, TaskState -from app.core.model.conversation_nodes import TaskNode +from app.core.model.conversation_enums import TaskState +from app.core.model.conversation_nodes import Task, _coerce_task_state from app.core.model.schemas.conversation_schema import ( ConversationSchema, TaskSchema, @@ -33,11 +33,15 @@ async def create_task( return None now = utcnow() task_id = task.id or new_doc_id("TaskSchema") - task.conversation_id = conversation_id - task.message_id = message_id - task.created_at = task.created_at or now - task.updated_at = now - node = task.to_task_node(task_id) + node = task.model_copy( + update={ + "id": task_id, + "conversation_id": conversation_id, + "message_id": message_id, + "created_at": task.created_at or now, + "updated_at": now, + } + ) try: await self.client.insert_document( TaskSchema.from_pydantic(node), @@ -67,7 +71,7 @@ async def update_task(self, task_id: str, **fields: Any) -> bool: return False if not raw: return False - node = TaskNode.from_raw_dict(raw) + node = Task.from_raw_dict(raw) conv_id = node.conversation_id if "name" in fields: @@ -75,11 +79,7 @@ async def update_task(self, task_id: str, **fields: Any) -> bool: if "description" in fields: node.description = fields["description"] if "state" in fields: - st = fields["state"] - if isinstance(st, TaskState): - node.state = st.value - else: - node.state = str(st) + node.state = _coerce_task_state(fields["state"]) if "progress" in fields: node.progress = float(fields["progress"]) if "progress_message" in fields: @@ -88,7 +88,9 @@ async def update_task(self, task_id: str, **fields: Any) -> bool: node.workflow_name = fields["workflow_name"] if "workflow_params" in fields: wp = fields["workflow_params"] - node.workflow_params_json = json.dumps(wp) if wp is not None else None + node.workflow_params_json = ( + json.dumps(wp) if wp is not None else None + ) if "started_at" in fields: node.started_at = fields["started_at"] if "finished_at" in fields: @@ -123,12 +125,10 @@ async def _maybe_clear_active_task(self, conversation_id: str) -> None: try: open_tasks = await self._query_tasks_for_conversation( conversation_id, - states=list( - { - TaskState.PENDING.value, - TaskState.RUNNING.value, - } - ), + states=[ + TaskState.PENDING.value, + TaskState.RUNNING.value, + ], ) except Exception as exc: print(exc) @@ -149,7 +149,7 @@ async def _query_tasks_for_conversation( self, conversation_id: str, states: list[str], - ) -> list[TaskNode]: + ) -> list[Task]: if not states: return [] query = ( @@ -168,11 +168,11 @@ async def _query_tasks_for_conversation( except Exception as exc: print(exc) return [] - out: list[TaskNode] = [] + out: list[Task] = [] for row in result.get("bindings", []): raw = row.get("task_doc") if raw: - out.append(TaskNode.from_raw_dict(raw)) + out.append(Task.from_raw_dict(raw)) return out async def get_task(self, task_id: str) -> Task | None: @@ -183,5 +183,4 @@ async def get_task(self, task_id: str) -> Task | None: return None if not raw: return None - node = TaskNode.from_raw_dict(raw) - return Task.from_task_node(node) + return Task.from_raw_dict(raw) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 72da776b..f158df03 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -8,11 +8,9 @@ from app.agent.runner.task_manager import TaskManager from app.agent.llm.factory import LLMFactory from app.agent.llm.providers.openai_provider import OpenAIProvider -from app.agent.models.conversation_store import ( - InMemoryConversationStore, - SQLiteConversationStore, -) +from app.agent.conversation_store import TerminusConversationStore from app.agent.runner.executor import AgentExecutor +from app.core.repository.conversation import ConversationRepo from .api import root from .db.client import get_terminus_client, close_db_client @@ -47,13 +45,8 @@ async def lifespan(app: FastAPI): ) task_manager = TaskManager() - # 3. Initialize conversation store (in-memory or sqlite) - if agent_settings.conversation_store_backend == "sqlite": - conversation_store = SQLiteConversationStore( - db_path=agent_settings.conversation_store_sqlite_path - ) - else: - conversation_store = InMemoryConversationStore() + conversation_repo = ConversationRepo(db) + conversation_store = TerminusConversationStore(conversation_repo) # 4. Create the Executor (wires runner, LLM, and store together) executor = AgentExecutor( From 0adb1dcc01c576739fc23ff089531c0ef9f909df Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Fri, 20 Mar 2026 00:41:22 +0300 Subject: [PATCH 23/49] conversation logic improved --- src/backend/app/agent/conversation_store.py | 39 ++- src/backend/app/agent/realtime/__init__.py | 15 ++ .../app/agent/realtime/conversation_events.py | 38 +++ src/backend/app/agent/realtime/wire.py | 64 +++++ src/backend/app/agent/runner/executor.py | 186 +++++++++++++- src/backend/app/agent/runner/patch_builder.py | 59 +++++ src/backend/app/agent/runner/stream_buffer.py | 84 ++++++ src/backend/app/api/root.py | 5 + .../app/api/v1/conversations/__init__.py | 3 + src/backend/app/api/v1/conversations/deps.py | 38 +++ .../app/api/v1/conversations/routes.py | 240 ++++++++++++++++++ .../app/api/v1/conversations/schemas.py | 56 ++++ .../repository/conversation/conversations.py | 8 + .../app/core/repository/conversation/tasks.py | 32 +++ src/backend/app/core/socket/manager.py | 68 +++++ src/backend/app/main.py | 6 +- 16 files changed, 931 insertions(+), 10 deletions(-) create mode 100644 src/backend/app/agent/realtime/__init__.py create mode 100644 src/backend/app/agent/realtime/conversation_events.py create mode 100644 src/backend/app/agent/realtime/wire.py create mode 100644 src/backend/app/agent/runner/patch_builder.py create mode 100644 src/backend/app/agent/runner/stream_buffer.py create mode 100644 src/backend/app/api/v1/conversations/__init__.py create mode 100644 src/backend/app/api/v1/conversations/deps.py create mode 100644 src/backend/app/api/v1/conversations/routes.py create mode 100644 src/backend/app/api/v1/conversations/schemas.py diff --git a/src/backend/app/agent/conversation_store.py b/src/backend/app/agent/conversation_store.py index 9a0feb85..023417b7 100644 --- a/src/backend/app/agent/conversation_store.py +++ b/src/backend/app/agent/conversation_store.py @@ -24,7 +24,7 @@ async def add_message( self, conversation_id: str, message: ConversationMessage, - ) -> None: + ) -> str | None: ... async def get_conversation( @@ -32,11 +32,24 @@ async def get_conversation( ) -> Conversation | None: ... + async def get_conversation_metadata( + self, conversation_id: str + ) -> ConversationSummary | None: + ... + async def list_conversations( - self, limit: int = 50 + self, limit: int = 50, cursor: str | None = None ) -> list[ConversationSummary]: ... + async def list_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + ... + async def upsert_task_part( self, conversation_id: str, @@ -70,22 +83,38 @@ async def add_message( self, conversation_id: str, message: ConversationMessage, - ) -> None: + ) -> str | None: mid = await self._repo.add_message(conversation_id, message) if mid is None: raise ValueError( f"Failed to add message to conversation {conversation_id!r}" ) + return mid async def get_conversation( self, conversation_id: str ) -> Conversation | None: return await self._repo.get_conversation(conversation_id) + async def get_conversation_metadata( + self, conversation_id: str + ) -> ConversationSummary | None: + return await self._repo.get_conversation_summary(conversation_id) + async def list_conversations( - self, limit: int = 50 + self, limit: int = 50, cursor: str | None = None ) -> list[ConversationSummary]: - return await self._repo.list_conversations(limit=limit) + return await self._repo.list_conversations(limit=limit, cursor=cursor) + + async def list_messages( + self, + conversation_id: str, + cursor: int = 0, + limit: int = 50, + ) -> list[ConversationMessage]: + return await self._repo.get_messages( + conversation_id, cursor=cursor, limit=limit + ) async def upsert_task_part( self, diff --git a/src/backend/app/agent/realtime/__init__.py b/src/backend/app/agent/realtime/__init__.py new file mode 100644 index 00000000..4e7d52cf --- /dev/null +++ b/src/backend/app/agent/realtime/__init__.py @@ -0,0 +1,15 @@ +"""WebSocket payloads and domain → client wire shapes for conversations.""" + +from app.agent.realtime.conversation_events import ( + conversation_room, + emit_conversation_patch, + emit_to_conversation, +) +from app.agent.realtime.wire import conversation_message_to_wire + +__all__ = [ + "conversation_message_to_wire", + "conversation_room", + "emit_conversation_patch", + "emit_to_conversation", +] diff --git a/src/backend/app/agent/realtime/conversation_events.py b/src/backend/app/agent/realtime/conversation_events.py new file mode 100644 index 00000000..011ad51d --- /dev/null +++ b/src/backend/app/agent/realtime/conversation_events.py @@ -0,0 +1,38 @@ +"""Socket.IO helpers for conversation rooms and patch envelopes.""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.core.socket.manager import get_socket_manager + +logger = logging.getLogger(__name__) + + +def conversation_room(conversation_id: str) -> str: + return f"conv:{conversation_id}" + + +async def emit_to_conversation( + conversation_id: str, + event: str, + data: dict[str, Any], +) -> None: + mgr = get_socket_manager() + room = conversation_room(conversation_id) + try: + await mgr.server.emit(event, data, room=room) + except Exception as e: + logger.error("emit_to_conversation failed: %s", e) + + +async def emit_conversation_patch( + conversation_id: str, + patches: list[dict[str, Any]], +) -> None: + await emit_to_conversation( + conversation_id, + "conversation:patch", + {"conversation_id": conversation_id, "patches": patches}, + ) diff --git a/src/backend/app/agent/realtime/wire.py b/src/backend/app/agent/realtime/wire.py new file mode 100644 index 00000000..04f498bd --- /dev/null +++ b/src/backend/app/agent/realtime/wire.py @@ -0,0 +1,64 @@ +"""Map domain models to the JSON shape the client keeps for patches / API responses.""" + +from __future__ import annotations + +from typing import Any + +from app.core.model.conversation_domain import ( + ConversationMessage, + ConversationSummary, + MessagePart, + TaskPart, + TextPart, + ToolCallPart, +) + + +def _part_to_wire(part: MessagePart) -> dict[str, Any]: + if isinstance(part, TextPart): + return {"type": "text", "text": part.text} + if isinstance(part, TaskPart): + return { + "type": "task", + "task_id": part.task_id, + "title": part.title, + "description": part.description, + "state": part.state.value, + "progress": part.progress, + "sub_tasks": [st.model_dump(mode="json") for st in part.sub_tasks], + "workflow_name": part.workflow_name, + } + if isinstance(part, ToolCallPart): + d: dict[str, Any] = { + "type": "tool_call", + "tool_name": part.tool_name, + "tool_input": part.tool_input, + } + if part.tool_output is not None: + d["tool_output"] = part.tool_output + return d + return part.model_dump(mode="json") # event / future parts + + +def conversation_message_to_wire(msg: ConversationMessage) -> dict[str, Any]: + return { + "id": msg.id, + "role": msg.role.value if hasattr(msg.role, "value") else str(msg.role), + "sequence": msg.sequence, + "parts": [_part_to_wire(p) for p in msg.parts], + "created_at": msg.created_at.isoformat(), + "token_count": msg.token_count, + "model": msg.model, + } + + +def conversation_summary_to_wire(s: ConversationSummary) -> dict[str, Any]: + return { + "id": s.id, + "title": s.title, + "description": s.description, + "message_count": s.message_count, + "has_active_task": s.has_active_task, + "created_at": s.created_at.isoformat(), + "updated_at": s.updated_at.isoformat(), + } diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index 7dd81dcd..366d781c 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -1,10 +1,22 @@ # agent/runner/executor.py +import asyncio +import logging import uuid +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from pydantic import BaseModel, Field + +from app.agent.conversation_store import ConversationStore +from app.agent.realtime import ( + conversation_message_to_wire, + emit_conversation_patch, + emit_to_conversation, +) +from app.agent.runner.patch_builder import ConversationPatchBuilder +from app.agent.runner.stream_buffer import StreamRegistry from app.agent.runner.task_manager import TaskManager from app.agent.workflows.base import BaseWorkflow -from app.agent.conversation_store import ConversationStore from app.core.model.conversation_domain import ( ConversationMessage, TaskPart, @@ -12,8 +24,8 @@ ) from app.core.model.conversation_enums import MessageRole, TaskState as ConversationTaskState from app.core.model.conversation_nodes import Task -from langchain_core.messages import HumanMessage, SystemMessage -from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) class ConversationTitleOutput(BaseModel): @@ -39,12 +51,180 @@ def __init__( task_manager: TaskManager, llm_factory, conversation_store: ConversationStore, + stream_registry: StreamRegistry | None = None, ): self.task_manager = task_manager self.llm_factory = llm_factory self.store = conversation_store + self.stream_registry = stream_registry or StreamRegistry() self._task_part_templates: dict[str, TaskPart] = {} + @staticmethod + def _text_from_domain_message(message: ConversationMessage) -> str: + texts = [p.text for p in message.parts if isinstance(p, TextPart)] + return "\n".join(texts) if texts else "" + + def _domain_messages_to_lc(self, messages: list[ConversationMessage]): + out = [] + for m in messages: + text = self._text_from_domain_message(m) + if m.role == MessageRole.USER: + out.append(HumanMessage(content=text)) + elif m.role == MessageRole.ASSISTANT: + out.append(AIMessage(content=text)) + return out + + async def handle_chat_message( + self, + conversation_id: str, + user_message: ConversationMessage, + ) -> dict: + """Persist user text, broadcast patch, and stream assistant reply in background.""" + user_mid = await self.store.add_message(conversation_id, user_message) + meta = await self.store.get_conversation_metadata(conversation_id) + if meta is None: + raise ValueError(f"Conversation not found: {conversation_id}") + + user_wire = conversation_message_to_wire( + user_message.model_copy( + update={ + "id": user_mid or user_message.id, + "sequence": meta.message_count - 1, + } + ) + ) + user_patches = ( + ConversationPatchBuilder() + .add_message_wire(user_wire) + .message_count(meta.message_count) + .build() + ) + await emit_conversation_patch(conversation_id, user_patches) + + stream_id = str(uuid.uuid4()) + self.stream_registry.create(stream_id, conversation_id) + + task_id = self.task_manager.submit( + name="chat:response", + coro_factory=self._generate_response, + conversation_id=conversation_id, + stream_id=stream_id, + ) + + return { + "conversation_id": conversation_id, + "message_id": user_mid, + "task_id": task_id, + "stream_id": stream_id, + } + + async def _generate_response( + self, + *, + conversation_id: str, + stream_id: str, + task_status: Task | None = None, + ) -> None: + buffer = self.stream_registry.get(stream_id) + if buffer is None: + logger.error("Missing stream buffer for stream_id=%s", stream_id) + return + + payload = { + "stream_id": stream_id, + "conversation_id": conversation_id, + } + if task_status is not None: + payload["task_id"] = task_status.id + + await emit_to_conversation( + conversation_id, + "stream:start", + payload, + ) + + try: + history = await self.store.list_messages( + conversation_id, cursor=0, limit=500 + ) + lc_messages = self._domain_messages_to_lc(history) + if not lc_messages: + lc_messages = [HumanMessage(content="")] + + provider = self.llm_factory.create(model="gpt-4o-mini") + async for delta in provider.stream(lc_messages): + if not delta: + continue + seq = buffer.append(delta) + await emit_to_conversation( + conversation_id, + "stream:chunk", + { + "stream_id": stream_id, + "seq": seq, + "delta": delta, + }, + ) + + full_text = buffer.finish() + assistant_id = str(uuid.uuid4()) + assistant_msg = ConversationMessage( + id=assistant_id, + role=MessageRole.ASSISTANT, + parts=[TextPart(text=full_text)], + ) + saved_id = await self.store.add_message( + conversation_id, assistant_msg + ) + final_id = saved_id or assistant_id + buffer.set_message_id(final_id) + + meta = await self.store.get_conversation_metadata(conversation_id) + if meta is None: + raise RuntimeError("conversation disappeared after save") + + msg_index = meta.message_count - 1 + seq_value = msg_index + finalize_patches = ( + ConversationPatchBuilder() + .finalize_assistant_text_part( + msg_index, + full_text, + message_id=final_id, + sequence=seq_value, + ) + .message_count(meta.message_count) + .build() + ) + await emit_conversation_patch(conversation_id, finalize_patches) + + await emit_to_conversation( + conversation_id, + "stream:end", + { + "stream_id": stream_id, + "message_id": final_id, + "total_seq": buffer.next_seq, + }, + ) + except asyncio.CancelledError: + await emit_to_conversation( + conversation_id, + "stream:error", + {"stream_id": stream_id, "error": "cancelled"}, + ) + raise + except Exception as e: + logger.exception("chat:response failed") + await emit_to_conversation( + conversation_id, + "stream:error", + {"stream_id": stream_id, "error": str(e)}, + ) + raise + finally: + self.stream_registry.schedule_remove(stream_id) + async def run_workflow( self, workflow: BaseWorkflow, diff --git a/src/backend/app/agent/runner/patch_builder.py b/src/backend/app/agent/runner/patch_builder.py new file mode 100644 index 00000000..e2116cb2 --- /dev/null +++ b/src/backend/app/agent/runner/patch_builder.py @@ -0,0 +1,59 @@ +"""RFC 6902 JSON Patch fragments for client conversation state.""" + +from __future__ import annotations + +from typing import Any + + +class ConversationPatchBuilder: + """Builds JSON Patch arrays for conversation state mutations.""" + + def __init__(self) -> None: + self._patches: list[dict[str, Any]] = [] + + def add(self, path: str, value: Any) -> ConversationPatchBuilder: + self._patches.append({"op": "add", "path": path, "value": value}) + return self + + def replace(self, path: str, value: Any) -> ConversationPatchBuilder: + self._patches.append({"op": "replace", "path": path, "value": value}) + return self + + def remove(self, path: str) -> ConversationPatchBuilder: + self._patches.append({"op": "remove", "path": path}) + return self + + def build(self) -> list[dict[str, Any]]: + return list(self._patches) + + def task_progress( + self, task_id: str, progress: float, message: str = "" + ) -> ConversationPatchBuilder: + self.replace(f"/tasks/{task_id}/progress", progress) + if message: + self.replace(f"/tasks/{task_id}/progress_message", message) + return self + + def add_message_wire(self, message: dict) -> ConversationPatchBuilder: + self.add("/messages/-", message) + return self + + def finalize_assistant_text_part( + self, + message_index: int, + text: str, + *, + message_id: str | None = None, + sequence: int | None = None, + ) -> ConversationPatchBuilder: + base = f"/messages/{message_index}" + self.replace(f"{base}/parts/0/text", text) + if message_id is not None: + self.replace(f"{base}/id", message_id) + if sequence is not None: + self.replace(f"{base}/sequence", sequence) + return self + + def message_count(self, n: int) -> ConversationPatchBuilder: + self.replace("/message_count", n) + return self diff --git a/src/backend/app/agent/runner/stream_buffer.py b/src/backend/app/agent/runner/stream_buffer.py new file mode 100644 index 00000000..18218028 --- /dev/null +++ b/src/backend/app/agent/runner/stream_buffer.py @@ -0,0 +1,84 @@ +"""In-memory LLM stream chunks with replay for WebSocket resume.""" + +from __future__ import annotations + +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +class StreamBuffer: + """Accumulates LLM token chunks with sequence numbers.""" + + def __init__(self, stream_id: str, conversation_id: str): + self.stream_id = stream_id + self.conversation_id = conversation_id + self._chunks: list[str] = [] + self._finished = False + self._final_text: str | None = None + self._message_id: str | None = None + + @property + def next_seq(self) -> int: + return len(self._chunks) + + def append(self, delta: str) -> int: + seq = self.next_seq + self._chunks.append(delta) + return seq + + def get_chunks_since(self, from_seq: int) -> list[tuple[int, str]]: + start = max(0, int(from_seq)) + return [(i, self._chunks[i]) for i in range(start, len(self._chunks))] + + def finish(self) -> str: + self._finished = True + self._final_text = "".join(self._chunks) + return self._final_text + + def set_message_id(self, message_id: str) -> None: + self._message_id = message_id + + @property + def message_id(self) -> str | None: + return self._message_id + + @property + def is_finished(self) -> bool: + return self._finished + + +class StreamRegistry: + """Tracks active streams; keeps finished buffers briefly for replay.""" + + def __init__(self, replay_ttl_s: float = 60.0): + self._active: dict[str, StreamBuffer] = {} + self.replay_ttl_s = replay_ttl_s + + def create(self, stream_id: str, conversation_id: str) -> StreamBuffer: + buf = StreamBuffer(stream_id, conversation_id) + self._active[stream_id] = buf + return buf + + def get(self, stream_id: str) -> StreamBuffer | None: + return self._active.get(stream_id) + + def remove(self, stream_id: str) -> None: + self._active.pop(stream_id, None) + + def schedule_remove(self, stream_id: str) -> None: + ttl = self.replay_ttl_s + + async def _delayed() -> None: + try: + await asyncio.sleep(ttl) + except asyncio.CancelledError: + return + self.remove(stream_id) + + try: + asyncio.create_task(_delayed()) + except RuntimeError: + logger.warning("Could not schedule stream buffer cleanup (no loop)") + diff --git a/src/backend/app/api/root.py b/src/backend/app/api/root.py index 97c24da8..20273183 100755 --- a/src/backend/app/api/root.py +++ b/src/backend/app/api/root.py @@ -9,6 +9,8 @@ from .v1.versioning import router as versioning_router # from .v1 import call_routes from .v1 import group_routes +from .v1.conversations import router as conversations_router +from .v1.conversations import tasks_router as conversation_tasks_router router = APIRouter() @@ -48,3 +50,6 @@ def get_root(): # router.include_router(call_routes.router, prefix="/calls", tags=["calls"]) router.include_router(group_routes.router, prefix="/groups", tags=["groups"]) + +router.include_router(conversations_router) +router.include_router(conversation_tasks_router) diff --git a/src/backend/app/api/v1/conversations/__init__.py b/src/backend/app/api/v1/conversations/__init__.py new file mode 100644 index 00000000..d1d86f59 --- /dev/null +++ b/src/backend/app/api/v1/conversations/__init__.py @@ -0,0 +1,3 @@ +from app.api.v1.conversations.routes import router, tasks_router + +__all__ = ["router", "tasks_router"] diff --git a/src/backend/app/api/v1/conversations/deps.py b/src/backend/app/api/v1/conversations/deps.py new file mode 100644 index 00000000..0810e24a --- /dev/null +++ b/src/backend/app/api/v1/conversations/deps.py @@ -0,0 +1,38 @@ +"""FastAPI dependencies for conversation + task APIs.""" + +from __future__ import annotations + +from fastapi import Request + +from app.agent.runner.executor import AgentExecutor +from app.agent.runner.task_manager import TaskManager +from app.agent.conversation_store import ConversationStore +from app.core.repository.conversation import ConversationRepo + + +def get_conversation_repo(request: Request) -> ConversationRepo: + repo = getattr(request.app.state, "conversation_repo", None) + if repo is None: + raise RuntimeError("conversation_repo not initialized on app.state") + return repo + + +def get_task_manager(request: Request) -> TaskManager: + tm = getattr(request.app.state, "task_manager", None) + if tm is None: + raise RuntimeError("task_manager not initialized on app.state") + return tm + + +def get_agent_executor(request: Request) -> AgentExecutor: + ex = getattr(request.app.state, "agent_executor", None) + if ex is None: + raise RuntimeError("agent_executor not initialized on app.state") + return ex + + +def get_conversation_store(request: Request) -> ConversationStore: + st = getattr(request.app.state, "conversation_store", None) + if st is None: + raise RuntimeError("conversation_store not initialized on app.state") + return st diff --git a/src/backend/app/api/v1/conversations/routes.py b/src/backend/app/api/v1/conversations/routes.py new file mode 100644 index 00000000..04297aab --- /dev/null +++ b/src/backend/app/api/v1/conversations/routes.py @@ -0,0 +1,240 @@ +"""Conversation and task HTTP API (Phase 4).""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from app.agent.runner.executor import AgentExecutor +from app.agent.runner.task_manager import TaskManager +from app.agent.conversation_store import ConversationStore +from app.agent.realtime.wire import conversation_message_to_wire +from app.api.v1.conversations.deps import ( + get_agent_executor, + get_conversation_repo, + get_conversation_store, + get_task_manager, +) +from app.api.v1.conversations.schemas import ( + ConversationMetaResponse, + CreateConversationRequest, + PaginatedItems, + PostMessageRequest, + PostMessageResponse, + subtask_to_wire, + task_to_wire, +) +from app.core.model.conversation_domain import ConversationMessage, TextPart +from app.core.model.conversation_enums import MessageRole + +router = APIRouter(prefix="/conversations", tags=["conversations"]) + +tasks_router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_conversation( + body: CreateConversationRequest, + store: ConversationStore = Depends(get_conversation_store), +) -> ConversationMetaResponse: + cid = await store.create_conversation(body.title, body.description) + meta = await store.get_conversation_metadata(cid) + if meta is None: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Conversation created but not readable", + ) + return ConversationMetaResponse( + id=meta.id, + title=meta.title, + description=meta.description, + message_count=meta.message_count, + has_active_task=meta.has_active_task, + created_at=meta.created_at, + updated_at=meta.updated_at, + metadata={}, + ) + + +@router.get("") +async def list_conversations( + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = None, + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + items = await store.list_conversations(limit=limit + 1, cursor=cursor) + has_more = len(items) > limit + page = items[:limit] + next_cursor = page[-1].id if has_more and page else None + return PaginatedItems( + items=[ + ConversationMetaResponse( + id=s.id, + title=s.title, + description=s.description, + message_count=s.message_count, + has_active_task=s.has_active_task, + created_at=s.created_at, + updated_at=s.updated_at, + metadata={}, + ) + for s in page + ], + next_cursor=next_cursor, + has_more=has_more, + ) + + +@router.get("/{conversation_id}") +async def get_conversation( + conversation_id: str, + store: ConversationStore = Depends(get_conversation_store), +) -> ConversationMetaResponse: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + full = await store.get_conversation(conversation_id) + md = full.metadata if full else {} + return ConversationMetaResponse( + id=meta.id, + title=meta.title, + description=meta.description, + message_count=meta.message_count, + has_active_task=meta.has_active_task, + created_at=meta.created_at, + updated_at=meta.updated_at, + metadata=md, + ) + + +@router.delete("/{conversation_id}") +async def delete_conversation(_conversation_id: str) -> None: + raise HTTPException( + status.HTTP_501_NOT_IMPLEMENTED, + detail="Not implemented for this storage backend", + ) + + +@router.get("/{conversation_id}/messages") +async def list_messages( + conversation_id: str, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + rows = await store.list_messages(conversation_id, cursor=cursor, limit=limit + 1) + has_more = len(rows) > limit + page = rows[:limit] + next_c: int | None = None + if has_more and page: + next_c = page[-1].sequence + 1 + return PaginatedItems( + items=[conversation_message_to_wire(m) for m in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@router.post( + "/{conversation_id}/messages", + status_code=status.HTTP_202_ACCEPTED, +) +async def post_message( + conversation_id: str, + body: PostMessageRequest, + executor: AgentExecutor = Depends(get_agent_executor), + store: ConversationStore = Depends(get_conversation_store), +) -> PostMessageResponse: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + + user_msg = ConversationMessage( + id=str(uuid.uuid4()), + role=MessageRole.USER, + parts=[TextPart(text=p.text) for p in body.parts], + ) + out = await executor.handle_chat_message(conversation_id, user_msg) + return PostMessageResponse( + message_id=out.get("message_id"), + task_id=out["task_id"], + conversation_id=out["conversation_id"], + stream_id=out["stream_id"], + ) + + +@router.get("/{conversation_id}/tasks") +async def list_conversation_tasks( + conversation_id: str, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + repo=Depends(get_conversation_repo), + store: ConversationStore = Depends(get_conversation_store), +) -> PaginatedItems: + meta = await store.get_conversation_metadata(conversation_id) + if meta is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + tasks = await repo.list_tasks_for_conversation( + conversation_id, limit=limit + 1, cursor=cursor + ) + has_more = len(tasks) > limit + page = tasks[:limit] + next_c = cursor + len(page) if has_more else None + return PaginatedItems( + items=[task_to_wire(t) for t in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@tasks_router.get("/{task_id}") +async def get_task( + task_id: str, + repo=Depends(get_conversation_repo), + tm: TaskManager = Depends(get_task_manager), +): + t = await repo.get_task(task_id) + if t is not None: + return task_to_wire(t) + mem = tm.get_status(task_id) + if mem is not None: + return task_to_wire(mem) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + + +@tasks_router.get("/{task_id}/subtasks") +async def list_subtasks( + task_id: str, + cursor: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + repo=Depends(get_conversation_repo), +) -> PaginatedItems: + if await repo.get_task(task_id) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found") + rows = await repo.get_subtasks(task_id, cursor=cursor, limit=limit + 1) + has_more = len(rows) > limit + page = rows[:limit] + next_c = page[-1].sequence + 1 if has_more and page else None + return PaginatedItems( + items=[subtask_to_wire(s) for s in page], + next_cursor=next_c, + has_more=has_more, + ) + + +@tasks_router.post("/{task_id}/cancel", status_code=status.HTTP_204_NO_CONTENT) +async def cancel_task( + task_id: str, + tm: TaskManager = Depends(get_task_manager), +) -> Response: + if not tm.cancel(task_id): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="Task not running or unknown", + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + diff --git a/src/backend/app/api/v1/conversations/schemas.py b/src/backend/app/api/v1/conversations/schemas.py new file mode 100644 index 00000000..ed5e6257 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas.py @@ -0,0 +1,56 @@ +"""REST DTOs for conversations (aligned with agentv2 docs).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class TextPartIn(BaseModel): + type: Literal["text"] = "text" + text: str + + +class PostMessageRequest(BaseModel): + role: Literal["user"] = "user" + parts: list[TextPartIn] = Field(..., min_length=1) + + +class CreateConversationRequest(BaseModel): + title: str = Field(default="New conversation", min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + + +class PostMessageResponse(BaseModel): + message_id: str | None + task_id: str + conversation_id: str + stream_id: str + + +class PaginatedItems(BaseModel): + items: list[Any] + next_cursor: str | int | None = None + has_more: bool = False + + +class ConversationMetaResponse(BaseModel): + id: str + title: str + description: str + message_count: int + has_active_task: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = Field(default_factory=dict) + + +def task_to_wire(t: Any) -> dict[str, Any]: + """Serialize `Task` node for JSON responses.""" + return t.model_dump(mode="json") + + +def subtask_to_wire(st: Any) -> dict[str, Any]: + return st.model_dump(mode="json") diff --git a/src/backend/app/core/repository/conversation/conversations.py b/src/backend/app/core/repository/conversation/conversations.py index bd904743..580a27ce 100644 --- a/src/backend/app/core/repository/conversation/conversations.py +++ b/src/backend/app/core/repository/conversation/conversations.py @@ -94,3 +94,11 @@ async def list_conversations( return [ ConversationSummary.from_conversation_node(n) for n in nodes[:cap] ] + + async def get_conversation_summary( + self, conversation_id: str + ) -> ConversationSummary | None: + conv = await self._get_conversation_node(conversation_id) + if conv is None: + return None + return ConversationSummary.from_conversation_node(conv) diff --git a/src/backend/app/core/repository/conversation/tasks.py b/src/backend/app/core/repository/conversation/tasks.py index df7c84ab..d5ded067 100644 --- a/src/backend/app/core/repository/conversation/tasks.py +++ b/src/backend/app/core/repository/conversation/tasks.py @@ -184,3 +184,35 @@ async def get_task(self, task_id: str) -> Task | None: if not raw: return None return Task.from_raw_dict(raw) + + async def list_tasks_for_conversation( + self, + conversation_id: str, + *, + limit: int = 50, + cursor: int = 0, + ) -> list[Task]: + query = ( + WQ() + .select("v:task_doc") + .woql_and( + WQ().triple("v:task", "conversation", conversation_id), + WQ().triple("v:task", "rdf:type", "@schema:TaskSchema"), + WQ().read_document("v:task", "v:task_doc"), + ) + ) + try: + result = await self.client.query(query) + except Exception as exc: + print(exc) + return [] + out: list[Task] = [] + for row in result.get("bindings", []): + raw = row.get("task_doc") + if raw: + out.append(Task.from_raw_dict(raw)) + out.sort(key=lambda t: t.created_at, reverse=True) + cursor = max(0, int(cursor)) + cap = max(1, int(limit)) + slice_start = cursor + return out[slice_start : slice_start + cap] diff --git a/src/backend/app/core/socket/manager.py b/src/backend/app/core/socket/manager.py index 79491343..2020fece 100644 --- a/src/backend/app/core/socket/manager.py +++ b/src/backend/app/core/socket/manager.py @@ -103,8 +103,13 @@ def __init__(self): # Prevent re-initialization if not hasattr(self, "initialized"): self.initialized = True + self._stream_registry = None self._setup_handlers() + def bind_stream_registry(self, registry) -> None: + """Process-wide stream buffers for `stream:resume` replay.""" + self._stream_registry = registry + def _setup_handlers(self): @self.server.event async def connect(sid, environ): @@ -151,6 +156,69 @@ async def leave_project(sid, project_id: str): ) await self.server.leave_room(sid, normalized_id) + @self.server.event + async def join_conversation(sid, conversation_id: str): + room = f"conv:{conversation_id}" + await self.server.enter_room(sid, room) + logger.info( + "Client %s... joined conversation room %s", + sid[:8], + conversation_id[:16], + ) + + @self.server.event + async def leave_conversation(sid, conversation_id: str): + room = f"conv:{conversation_id}" + await self.server.leave_room(sid, room) + + @self.server.on("stream:resume") + async def stream_resume(sid, data): + await self._handle_stream_resume(sid, data) + + async def _handle_stream_resume(self, sid, data: Any) -> None: + if not isinstance(data, dict): + await self.server.emit( + "stream:error", + {"stream_id": None, "error": "invalid_payload"}, + to=sid, + ) + return + stream_id = data.get("stream_id") + last_seq = data.get("last_seq", -1) + reg = self._stream_registry + if reg is None: + await self.server.emit( + "stream:error", + {"stream_id": stream_id, "error": "server_misconfigured"}, + to=sid, + ) + return + buf = reg.get(stream_id) if stream_id else None + if buf is None: + await self.server.emit( + "stream:error", + {"stream_id": stream_id, "error": "stream_expired"}, + to=sid, + ) + return + start = int(last_seq) + 1 + for seq, delta in buf.get_chunks_since(start): + await self.server.emit( + "stream:chunk", + {"stream_id": stream_id, "seq": seq, "delta": delta}, + to=sid, + ) + if buf.is_finished and buf.message_id: + await self.server.emit( + "stream:end", + { + "stream_id": stream_id, + "message_id": buf.message_id, + "total_seq": buf.next_seq, + }, + to=sid, + ) + def _normalize_project_id(self, project_id: str) -> str: """Normalize project_id to key format (remove nodes/ prefix).""" # Extract key part if project_id has nodes/ prefix diff --git a/src/backend/app/main.py b/src/backend/app/main.py index f158df03..efeb1fea 100755 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -52,17 +52,19 @@ async def lifespan(app: FastAPI): executor = AgentExecutor( task_manager=task_manager, llm_factory=llm_factory, - conversation_store=conversation_store + conversation_store=conversation_store, ) # Attach to app state for dependency injection to use later app.state.agent_executor = executor app.state.task_manager = task_manager app.state.conversation_store = conversation_store + app.state.conversation_repo = conversation_repo app.state.watcher_service = watcher_service # Init Socket Manager (creates the server instance) - _ = get_socket_manager() + socket_manager = get_socket_manager() + socket_manager.bind_stream_registry(executor.stream_registry) print("🔌 Socket.IO server initialized and ready") yield From c4f4cb074a201a07ef3c25173f011d7d7dd5f9b4 Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Fri, 20 Mar 2026 01:00:50 +0300 Subject: [PATCH 24/49] conversation added --- src/backend/app/agent/chat/__init__.py | 5 ++ .../app/agent/chat/completion_params.py | 36 ++++++++++ src/backend/app/agent/runner/executor.py | 35 +++++++++- .../app/api/v1/conversations/mappers.py | 21 ++++++ .../app/api/v1/conversations/params.py | 27 ++++++++ .../app/api/v1/conversations/routes.py | 69 ++++++++++++------- .../app/api/v1/conversations/schemas.py | 56 --------------- .../api/v1/conversations/schemas/__init__.py | 25 +++++++ .../v1/conversations/schemas/pagination.py | 11 +++ .../app/api/v1/conversations/schemas/parts.py | 20 ++++++ .../api/v1/conversations/schemas/requests.py | 49 +++++++++++++ .../api/v1/conversations/schemas/responses.py | 25 +++++++ .../app/api/v1/conversations/schemas/tasks.py | 11 +++ 13 files changed, 308 insertions(+), 82 deletions(-) create mode 100644 src/backend/app/agent/chat/__init__.py create mode 100644 src/backend/app/agent/chat/completion_params.py create mode 100644 src/backend/app/api/v1/conversations/mappers.py create mode 100644 src/backend/app/api/v1/conversations/params.py delete mode 100644 src/backend/app/api/v1/conversations/schemas.py create mode 100644 src/backend/app/api/v1/conversations/schemas/__init__.py create mode 100644 src/backend/app/api/v1/conversations/schemas/pagination.py create mode 100644 src/backend/app/api/v1/conversations/schemas/parts.py create mode 100644 src/backend/app/api/v1/conversations/schemas/requests.py create mode 100644 src/backend/app/api/v1/conversations/schemas/responses.py create mode 100644 src/backend/app/api/v1/conversations/schemas/tasks.py diff --git a/src/backend/app/agent/chat/__init__.py b/src/backend/app/agent/chat/__init__.py new file mode 100644 index 00000000..74d52246 --- /dev/null +++ b/src/backend/app/agent/chat/__init__.py @@ -0,0 +1,5 @@ +"""Chat completion and message-adjacent agent types (shared API + runner).""" + +from app.agent.chat.completion_params import ChatCompletionParams + +__all__ = ["ChatCompletionParams"] diff --git a/src/backend/app/agent/chat/completion_params.py b/src/backend/app/agent/chat/completion_params.py new file mode 100644 index 00000000..966baf5d --- /dev/null +++ b/src/backend/app/agent/chat/completion_params.py @@ -0,0 +1,36 @@ +"""LLM call parameters for chat turns (REST and executor share this model).""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ChatCompletionParams(BaseModel): + """ + Overrides for a single assistant generation. + Omitted fields fall back to `AgentConfig` / provider defaults. + """ + + model_config = ConfigDict(extra="ignore") + + provider: str | None = Field( + default=None, + description="Registered provider name, e.g. openai", + ) + model: str | None = Field( + default=None, + description="Provider model id, e.g. gpt-4o-mini", + ) + temperature: float | None = Field(default=None, ge=0.0, le=2.0) + max_tokens: int | None = Field(default=None, ge=1, le=128_000) + top_p: float | None = Field(default=None, ge=0.0, le=1.0) + frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + stop: list[str] | None = Field(default=None, max_length=8) + + def provider_create_kwargs(self) -> dict: + """Keyword args for `LLMFactory.create` / ChatOpenAI (excluding provider+model).""" + return self.model_dump( + exclude={"provider", "model"}, + exclude_none=True, + ) diff --git a/src/backend/app/agent/runner/executor.py b/src/backend/app/agent/runner/executor.py index 366d781c..fcb361b6 100644 --- a/src/backend/app/agent/runner/executor.py +++ b/src/backend/app/agent/runner/executor.py @@ -7,6 +7,8 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from pydantic import BaseModel, Field +from app.agent.chat.completion_params import ChatCompletionParams +from app.agent.config import settings as agent_settings from app.agent.conversation_store import ConversationStore from app.agent.realtime import ( conversation_message_to_wire, @@ -74,10 +76,28 @@ def _domain_messages_to_lc(self, messages: list[ConversationMessage]): out.append(AIMessage(content=text)) return out + def _resolve_llm(self, completion_params: ChatCompletionParams | None): + params = completion_params or ChatCompletionParams() + model = params.model or agent_settings.default_model + provider_name = params.provider or agent_settings.default_provider + extra = params.provider_create_kwargs() + mt = extra.get("max_tokens") + if mt is not None and mt > agent_settings.max_total_tokens: + extra["max_tokens"] = agent_settings.max_total_tokens + llm = self.llm_factory.create( + provider=provider_name, + model=model, + **extra, + ) + return llm, model, provider_name + async def handle_chat_message( self, conversation_id: str, user_message: ConversationMessage, + *, + completion_params: ChatCompletionParams | None = None, + client_ref: str | None = None, ) -> dict: """Persist user text, broadcast patch, and stream assistant reply in background.""" user_mid = await self.store.add_message(conversation_id, user_message) @@ -109,6 +129,8 @@ async def handle_chat_message( coro_factory=self._generate_response, conversation_id=conversation_id, stream_id=stream_id, + completion_params=completion_params, + client_ref=client_ref, ) return { @@ -116,6 +138,7 @@ async def handle_chat_message( "message_id": user_mid, "task_id": task_id, "stream_id": stream_id, + "client_ref": client_ref, } async def _generate_response( @@ -124,18 +147,28 @@ async def _generate_response( conversation_id: str, stream_id: str, task_status: Task | None = None, + completion_params: ChatCompletionParams | None = None, + client_ref: str | None = None, ) -> None: buffer = self.stream_registry.get(stream_id) if buffer is None: logger.error("Missing stream buffer for stream_id=%s", stream_id) return + provider, resolved_model, resolved_provider = self._resolve_llm( + completion_params + ) + payload = { "stream_id": stream_id, "conversation_id": conversation_id, + "model": resolved_model, + "provider": resolved_provider, } if task_status is not None: payload["task_id"] = task_status.id + if client_ref: + payload["client_ref"] = client_ref await emit_to_conversation( conversation_id, @@ -151,7 +184,6 @@ async def _generate_response( if not lc_messages: lc_messages = [HumanMessage(content="")] - provider = self.llm_factory.create(model="gpt-4o-mini") async for delta in provider.stream(lc_messages): if not delta: continue @@ -172,6 +204,7 @@ async def _generate_response( id=assistant_id, role=MessageRole.ASSISTANT, parts=[TextPart(text=full_text)], + model=resolved_model, ) saved_id = await self.store.add_message( conversation_id, assistant_msg diff --git a/src/backend/app/api/v1/conversations/mappers.py b/src/backend/app/api/v1/conversations/mappers.py new file mode 100644 index 00000000..8330122e --- /dev/null +++ b/src/backend/app/api/v1/conversations/mappers.py @@ -0,0 +1,21 @@ +"""Map validated API DTOs to domain models.""" + +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.api.v1.conversations.schemas.parts import TextPartIn +from app.core.model.conversation_domain import MessagePart, TextPart + + +def message_parts_to_domain(parts: list) -> list[MessagePart]: + out: list[MessagePart] = [] + for p in parts: + if isinstance(p, TextPartIn): + out.append(TextPart(text=p.text)) + else: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported message part: {type(p).__name__}", + ) + return out diff --git a/src/backend/app/api/v1/conversations/params.py b/src/backend/app/api/v1/conversations/params.py new file mode 100644 index 00000000..d1eb4e54 --- /dev/null +++ b/src/backend/app/api/v1/conversations/params.py @@ -0,0 +1,27 @@ +"""Reusable FastAPI Query declarations for TerminusDB document ids (may contain `/`).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Query + +ConversationIdQuery = Annotated[ + str, + Query( + ..., + min_length=1, + max_length=512, + description="Full TerminusDB id (e.g. ConversationSchema/uuid). URL-encode `/` as %2F.", + ), +] + +TaskIdQuery = Annotated[ + str, + Query( + ..., + min_length=1, + max_length=512, + description="Full task document id or in-memory task id. URL-encode `/` as %2F.", + ), +] diff --git a/src/backend/app/api/v1/conversations/routes.py b/src/backend/app/api/v1/conversations/routes.py index 04297aab..5ca2cd61 100644 --- a/src/backend/app/api/v1/conversations/routes.py +++ b/src/backend/app/api/v1/conversations/routes.py @@ -1,4 +1,8 @@ -"""Conversation and task HTTP API (Phase 4).""" +"""Conversation and task HTTP API (Phase 4). + +TerminusDB @id values often contain `/` (e.g. ConversationSchema/uuid), which breaks +path segments. Resource ids are passed as query parameters instead. +""" from __future__ import annotations @@ -16,16 +20,18 @@ get_conversation_store, get_task_manager, ) +from app.api.v1.conversations.mappers import message_parts_to_domain +from app.api.v1.conversations.params import ConversationIdQuery, TaskIdQuery from app.api.v1.conversations.schemas import ( ConversationMetaResponse, CreateConversationRequest, PaginatedItems, - PostMessageRequest, PostMessageResponse, + SendConversationMessageRequest, subtask_to_wire, task_to_wire, ) -from app.core.model.conversation_domain import ConversationMessage, TextPart +from app.core.model.conversation_domain import ConversationMessage from app.core.model.conversation_enums import MessageRole router = APIRouter(prefix="/conversations", tags=["conversations"]) @@ -86,9 +92,9 @@ async def list_conversations( ) -@router.get("/{conversation_id}") +@router.get("/meta") async def get_conversation( - conversation_id: str, + conversation_id: ConversationIdQuery, store: ConversationStore = Depends(get_conversation_store), ) -> ConversationMetaResponse: meta = await store.get_conversation_metadata(conversation_id) @@ -108,17 +114,19 @@ async def get_conversation( ) -@router.delete("/{conversation_id}") -async def delete_conversation(_conversation_id: str) -> None: +@router.delete("") +async def delete_conversation( + conversation_id: ConversationIdQuery, +) -> None: raise HTTPException( status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented for this storage backend", ) -@router.get("/{conversation_id}/messages") +@router.get("/messages") async def list_messages( - conversation_id: str, + conversation_id: ConversationIdQuery, cursor: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), store: ConversationStore = Depends(get_conversation_store), @@ -140,36 +148,43 @@ async def list_messages( @router.post( - "/{conversation_id}/messages", + "/messages", status_code=status.HTTP_202_ACCEPTED, ) async def post_message( - conversation_id: str, - body: PostMessageRequest, + body: SendConversationMessageRequest, executor: AgentExecutor = Depends(get_agent_executor), store: ConversationStore = Depends(get_conversation_store), ) -> PostMessageResponse: - meta = await store.get_conversation_metadata(conversation_id) + cid = body.conversation_id + meta = await store.get_conversation_metadata(cid) if meta is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") + domain_parts = message_parts_to_domain(body.parts) user_msg = ConversationMessage( id=str(uuid.uuid4()), role=MessageRole.USER, - parts=[TextPart(text=p.text) for p in body.parts], + parts=domain_parts, + ) + out = await executor.handle_chat_message( + cid, + user_msg, + completion_params=body.generation, + client_ref=body.client_ref, ) - out = await executor.handle_chat_message(conversation_id, user_msg) return PostMessageResponse( message_id=out.get("message_id"), task_id=out["task_id"], conversation_id=out["conversation_id"], stream_id=out["stream_id"], + client_ref=body.client_ref, ) -@router.get("/{conversation_id}/tasks") +@router.get("/tasks") async def list_conversation_tasks( - conversation_id: str, + conversation_id: ConversationIdQuery, cursor: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), repo=Depends(get_conversation_repo), @@ -191,9 +206,9 @@ async def list_conversation_tasks( ) -@tasks_router.get("/{task_id}") +@tasks_router.get("/detail") async def get_task( - task_id: str, + task_id: TaskIdQuery, repo=Depends(get_conversation_repo), tm: TaskManager = Depends(get_task_manager), ): @@ -206,15 +221,20 @@ async def get_task( raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Not found") -@tasks_router.get("/{task_id}/subtasks") +@tasks_router.get("/subtasks") async def list_subtasks( - task_id: str, + task_id: TaskIdQuery, cursor: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), repo=Depends(get_conversation_repo), + tm: TaskManager = Depends(get_task_manager), ) -> PaginatedItems: if await repo.get_task(task_id) is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found") + if tm.get_status(task_id) is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + return PaginatedItems(items=[], next_cursor=None, has_more=False) rows = await repo.get_subtasks(task_id, cursor=cursor, limit=limit + 1) has_more = len(rows) > limit page = rows[:limit] @@ -226,9 +246,9 @@ async def list_subtasks( ) -@tasks_router.post("/{task_id}/cancel", status_code=status.HTTP_204_NO_CONTENT) +@tasks_router.post("/cancel", status_code=status.HTTP_204_NO_CONTENT) async def cancel_task( - task_id: str, + task_id: TaskIdQuery, tm: TaskManager = Depends(get_task_manager), ) -> Response: if not tm.cancel(task_id): @@ -237,4 +257,3 @@ async def cancel_task( detail="Task not running or unknown", ) return Response(status_code=status.HTTP_204_NO_CONTENT) - diff --git a/src/backend/app/api/v1/conversations/schemas.py b/src/backend/app/api/v1/conversations/schemas.py deleted file mode 100644 index ed5e6257..00000000 --- a/src/backend/app/api/v1/conversations/schemas.py +++ /dev/null @@ -1,56 +0,0 @@ -"""REST DTOs for conversations (aligned with agentv2 docs).""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class TextPartIn(BaseModel): - type: Literal["text"] = "text" - text: str - - -class PostMessageRequest(BaseModel): - role: Literal["user"] = "user" - parts: list[TextPartIn] = Field(..., min_length=1) - - -class CreateConversationRequest(BaseModel): - title: str = Field(default="New conversation", min_length=1, max_length=200) - description: str = Field(default="", max_length=2000) - - -class PostMessageResponse(BaseModel): - message_id: str | None - task_id: str - conversation_id: str - stream_id: str - - -class PaginatedItems(BaseModel): - items: list[Any] - next_cursor: str | int | None = None - has_more: bool = False - - -class ConversationMetaResponse(BaseModel): - id: str - title: str - description: str - message_count: int - has_active_task: bool - created_at: datetime - updated_at: datetime - metadata: dict[str, Any] = Field(default_factory=dict) - - -def task_to_wire(t: Any) -> dict[str, Any]: - """Serialize `Task` node for JSON responses.""" - return t.model_dump(mode="json") - - -def subtask_to_wire(st: Any) -> dict[str, Any]: - return st.model_dump(mode="json") diff --git a/src/backend/app/api/v1/conversations/schemas/__init__.py b/src/backend/app/api/v1/conversations/schemas/__init__.py new file mode 100644 index 00000000..2d2c8114 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/__init__.py @@ -0,0 +1,25 @@ +"""Conversation API DTOs (pagination, parts, requests, responses).""" + +from app.api.v1.conversations.schemas.pagination import PaginatedItems +from app.api.v1.conversations.schemas.parts import MessagePartIn, TextPartIn +from app.api.v1.conversations.schemas.requests import ( + CreateConversationRequest, + SendConversationMessageRequest, +) +from app.api.v1.conversations.schemas.responses import ( + ConversationMetaResponse, + PostMessageResponse, +) +from app.api.v1.conversations.schemas.tasks import subtask_to_wire, task_to_wire + +__all__ = [ + "ConversationMetaResponse", + "CreateConversationRequest", + "MessagePartIn", + "PaginatedItems", + "PostMessageResponse", + "SendConversationMessageRequest", + "TextPartIn", + "subtask_to_wire", + "task_to_wire", +] diff --git a/src/backend/app/api/v1/conversations/schemas/pagination.py b/src/backend/app/api/v1/conversations/schemas/pagination.py new file mode 100644 index 00000000..cd039c34 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/pagination.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class PaginatedItems(BaseModel): + items: list[Any] = Field(default_factory=list) + next_cursor: str | int | None = None + has_more: bool = False diff --git a/src/backend/app/api/v1/conversations/schemas/parts.py b/src/backend/app/api/v1/conversations/schemas/parts.py new file mode 100644 index 00000000..4298ee2d --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/parts.py @@ -0,0 +1,20 @@ +""" +API message parts. + +Use a discriminated union when more than one `type` exists (see Pydantic docs). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class TextPartIn(BaseModel): + type: Literal["text"] = "text" + text: str = Field(..., min_length=1, max_length=1_000_000) + + +# Alias so request models and OpenAPI stay stable when new part types ship. +MessagePartIn = TextPartIn diff --git a/src/backend/app/api/v1/conversations/schemas/requests.py b/src/backend/app/api/v1/conversations/schemas/requests.py new file mode 100644 index 00000000..70ce4907 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/requests.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from app.agent.chat.completion_params import ChatCompletionParams +from app.api.v1.conversations.schemas.parts import TextPartIn + + +class CreateConversationRequest(BaseModel): + title: str = Field(default="New conversation", min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + + +class SendConversationMessageRequest(BaseModel): + """ + Submit a user turn and schedule streamed assistant generation. + + `conversation_id` is the full TerminusDB document id (may contain `/`); + clients should URL-encode it when passed as a query parameter elsewhere. + """ + + conversation_id: str = Field( + ..., + min_length=1, + max_length=512, + description="Full conversation document id, e.g. ConversationSchema/", + ) + role: Literal["user"] = "user" + parts: list[TextPartIn] = Field( + ..., + min_length=1, + max_length=64, + description="Ordered segments; schema uses `type` for forward-compatible unions.", + ) + generation: ChatCompletionParams | None = Field( + default=None, + description="Optional per-request LLM overrides.", + ) + client_ref: str | None = Field( + default=None, + max_length=128, + description="Optional idempotency or correlation key for the client.", + ) + metadata: dict[str, Any] | None = Field( + default=None, + description="Opaque envelope; not interpreted by the runner today.", + ) diff --git a/src/backend/app/api/v1/conversations/schemas/responses.py b/src/backend/app/api/v1/conversations/schemas/responses.py new file mode 100644 index 00000000..7b938c20 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/responses.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class PostMessageResponse(BaseModel): + message_id: str | None + task_id: str + conversation_id: str + stream_id: str + client_ref: str | None = None + + +class ConversationMetaResponse(BaseModel): + id: str + title: str + description: str + message_count: int + has_active_task: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/backend/app/api/v1/conversations/schemas/tasks.py b/src/backend/app/api/v1/conversations/schemas/tasks.py new file mode 100644 index 00000000..fcd86ee3 --- /dev/null +++ b/src/backend/app/api/v1/conversations/schemas/tasks.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + + +def task_to_wire(t: Any) -> dict[str, Any]: + return t.model_dump(mode="json") + + +def subtask_to_wire(st: Any) -> dict[str, Any]: + return st.model_dump(mode="json") From 8bb7fd1186603aff8f675314f055b742150eff2b Mon Sep 17 00:00:00 2001 From: yaredtsy Date: Fri, 20 Mar 2026 01:20:24 +0300 Subject: [PATCH 25/49] frontend setuped --- src/frontend/package.json | 5 +- .../Agent/components/AgentChatInput.tsx | 46 ++++- .../Agent/components/AgentOverlay.tsx | 177 ++++++++++++++---- .../Agent/components/AgentSidebar.tsx | 83 ++++++-- .../Agent/components/messages/MessageItem.tsx | 50 +++++ .../Agent/components/messages/MessageList.tsx | 28 +++ .../Agent/components/messages/index.ts | 4 + .../Agent/components/messages/wireText.ts | 14 ++ .../features/Agent/live/hooks/index.ts | 3 + .../Agent/live/hooks/useAgentChatSession.ts | 10 + .../live/hooks/useAgentConversationSocket.ts | 84 +++++++++ .../live/hooks/useAgentConversationSync.ts | 36 ++++ .../Dashboard/features/Agent/live/index.ts | 2 + .../Agent/live/store/useAgentLiveStore.ts | 169 +++++++++++++++++ .../features/Agent/store/useAgentUiStore.ts | 21 +++ src/frontend/src/lib/agentFetch.ts | 45 +++++ .../lib/jsonPatch/applyConversationPatches.ts | 31 +++ src/frontend/src/lib/queryKeys.ts | 10 + src/frontend/src/lib/react/useEffectEvent.ts | 22 +++ src/frontend/src/services/agent/api.ts | 77 ++++++++ src/frontend/src/services/agent/index.ts | 6 + src/frontend/src/services/agent/mutations.ts | 16 ++ src/frontend/src/services/agent/queries.ts | 21 +++ .../src/services/socket/SocketProvider.tsx | 29 +-- src/frontend/src/services/socket/hooks.ts | 20 +- src/frontend/src/services/socket/index.ts | 2 +- src/frontend/src/services/socket/socket.ts | 47 ++++- src/frontend/src/services/socket/types.ts | 29 ++- src/frontend/src/types/agent/api.ts | 29 +++ src/frontend/src/types/agent/conversation.ts | 44 +++++ src/frontend/src/types/agent/generation.ts | 12 ++ src/frontend/src/types/agent/index.ts | 4 + src/frontend/src/types/agent/stream.ts | 36 ++++ src/frontend/yarn.lock | 30 +-- 34 files changed, 1144 insertions(+), 98 deletions(-) create mode 100644 src/frontend/src/features/Dashboard/features/Agent/components/messages/MessageItem.tsx create mode 100644 src/frontend/src/features/Dashboard/features/Agent/components/messages/MessageList.tsx create mode 100644 src/frontend/src/features/Dashboard/features/Agent/components/messages/index.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/components/messages/wireText.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/hooks/index.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/hooks/useAgentChatSession.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/hooks/useAgentConversationSocket.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/hooks/useAgentConversationSync.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/index.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/live/store/useAgentLiveStore.ts create mode 100644 src/frontend/src/features/Dashboard/features/Agent/store/useAgentUiStore.ts create mode 100644 src/frontend/src/lib/agentFetch.ts create mode 100644 src/frontend/src/lib/jsonPatch/applyConversationPatches.ts create mode 100644 src/frontend/src/lib/react/useEffectEvent.ts create mode 100644 src/frontend/src/services/agent/api.ts create mode 100644 src/frontend/src/services/agent/index.ts create mode 100644 src/frontend/src/services/agent/mutations.ts create mode 100644 src/frontend/src/services/agent/queries.ts create mode 100644 src/frontend/src/types/agent/api.ts create mode 100644 src/frontend/src/types/agent/conversation.ts create mode 100644 src/frontend/src/types/agent/generation.ts create mode 100644 src/frontend/src/types/agent/index.ts create mode 100644 src/frontend/src/types/agent/stream.ts diff --git a/src/frontend/package.json b/src/frontend/package.json index 17bb0126..0770af7f 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -54,14 +54,15 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "driver.js": "^1.4.0", + "fast-json-patch": "^3.1.1", "immer": "^10.1.1", "lucide-react": "^0.532.0", "mermaid": "^11.9.0", "next-themes": "^0.4.6", "postcss": "^8.5.6", - "react": "^19.1.0", + "react": "^19.2.0", "react-day-picker": "^9.8.1", - "react-dom": "^19.1.0", + "react-dom": "^19.2.0", "react-hook-form": "^7.61.1", "react-icons": "^5.5.0", "react-resizable-panels": "^3.0.3", diff --git a/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx b/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx index dba7563f..93abb573 100644 --- a/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx +++ b/src/frontend/src/features/Dashboard/features/Agent/components/AgentChatInput.tsx @@ -1,8 +1,11 @@ import { useState } from "react"; import { SendHorizontal } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useSendAgentMessage } from "@/services/agent"; +import { useAgentUiStore } from "../store/useAgentUiStore"; interface AgentChatInputProps { className?: string; @@ -10,10 +13,35 @@ interface AgentChatInputProps { export function AgentChatInput({ className }: AgentChatInputProps) { const [value, setValue] = useState(""); + const backendConversationId = useAgentUiStore((s) => s.backendConversationId); + const sendMessage = useSendAgentMessage(); const handleSubmit = () => { - // Input UI only for now; sending logic is intentionally out of scope. - setValue(""); + const text = value.trim(); + if (!text) return; + + if (!backendConversationId) { + toast.message("Select a server conversation", { + description: + "Open chat history and pick a conversation from the API list (Live), or use local demos without sending.", + }); + return; + } + + sendMessage.mutate( + { + conversation_id: backendConversationId, + parts: [{ type: "text", text }], + }, + { + onSuccess: () => setValue(""), + onError: (e) => { + toast.error("Failed to send message", { + description: e instanceof Error ? e.message : String(e), + }); + }, + }, + ); }; return ( @@ -21,14 +49,24 @@ export function AgentChatInput({ className }: AgentChatInputProps) { setValue(event.target.value)} - placeholder="Ask AI about this code..." + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + placeholder={ + backendConversationId + ? "Message…" + : "Select a live conversation to send…" + } className="h-9 text-xs" /> +
setSearchTerm(event.target.value)} - placeholder="Search chats..." + placeholder="Search…" className="h-8 text-xs" />
-
- {filteredHistory.length > 0 ? ( - filteredHistory.map((item) => ( - - )) - ) : ( -

- No chat history found. + +

+
+

+ Server (live) +

+ {serverQuery.isError ? ( +

+ Could not load conversations. Is the API running? +

+ ) : serverQuery.isPending ? ( +

+ Loading… +

+ ) : filteredServer.length > 0 ? ( +
+ {filteredServer.map((item) => ( + + ))} +
+ ) : ( +

+ No server conversations. +

+ )} +
+ +
+

+ Local demos

- )} + {filteredFixtures.length > 0 ? ( +
+ {filteredFixtures.map((item) => ( + + ))} +
+ ) : ( +

+ No local demos. +

+ )} +
diff --git a/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx b/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx index 133fe01f..c4b76ce4 100644 --- a/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx +++ b/src/frontend/src/features/Dashboard/features/Agent/components/AgentSidebar.tsx @@ -1,15 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; +import { agentConversationHydrationQueryOptions } from "@/services/agent"; import { useConversationStore } from "../store/useConversationStore"; +import { useAgentUiStore } from "../store/useAgentUiStore"; import { selectMessageText } from "../store/selectors/conversationSelectors"; import { useShallow } from "zustand/react/shallow"; +import { useAgentChatSession } from "../live"; +import { useAgentLiveStore } from "../live/store/useAgentLiveStore"; import { AgentChatInput } from "./AgentChatInput"; import { WalkthroughView } from "./WalkthroughView/WalkthroughView"; +import { + MessageList, + messageItemFromWire, + type MessageItemProps, +} from "./messages"; interface AgentSidebarProps { className?: string; } export function AgentSidebar({ className }: AgentSidebarProps) { + const backendConversationId = useAgentUiStore((s) => s.backendConversationId); + useAgentChatSession(backendConversationId); + + const wire = useAgentLiveStore((s) => s.wire); + const activeStreams = useAgentLiveStore((s) => s.activeStreams); + + const hydrationQuery = useQuery( + agentConversationHydrationQueryOptions(backendConversationId), + ); + const isLiveLoading = + Boolean(backendConversationId) && hydrationQuery.isPending; + const [viewMode, setViewMode, currentConversation] = useConversationStore( useShallow((state) => [ state.viewMode, @@ -18,7 +40,32 @@ export function AgentSidebar({ className }: AgentSidebarProps) { ]), ); - const messages = currentConversation?.messages; + const isLive = + Boolean(backendConversationId) && + wire?.id === backendConversationId; + + const streamingPlaceholderIds = new Set( + [...activeStreams].map((sid) => `stream:${sid}`), + ); + + let listMessages: MessageItemProps[] = []; + if (isLive && wire) { + listMessages = wire.messages.map((m) => + messageItemFromWire(m, { + streaming: streamingPlaceholderIds.has(m.id), + }), + ); + } else if (currentConversation?.messages?.length) { + listMessages = currentConversation.messages.map((m) => ({ + id: m.id, + role: m.role, + text: selectMessageText(m.parts), + })); + } + + const title = isLive + ? (wire?.title ?? "Loading…") + : (currentConversation?.title ?? "No conversation selected"); return (