Compare commits
1039 Commits
test/real-
...
codex/vect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab1d0a4a6 | ||
|
|
b69e2555cc | ||
|
|
29158db347 | ||
|
|
638008507b | ||
|
|
5ab31b78e7 | ||
|
|
189cc209dd | ||
|
|
039694b1d3 | ||
|
|
59830a7282 | ||
|
|
34f3955154 | ||
|
|
f09af0c148 | ||
|
|
104bbe199e | ||
|
|
aca6580790 | ||
|
|
1dc6a447c4 | ||
|
|
8c3e7383b4 | ||
|
|
686e760d8c | ||
|
|
e781e78124 | ||
|
|
74cb7ba6ca | ||
|
|
7b5f6a2e80 | ||
|
|
fc50e67142 | ||
|
|
f094e3f415 | ||
|
|
7b482e4dab | ||
|
|
898373ec53 | ||
|
|
0323ba2ca3 | ||
|
|
9583c98a06 | ||
|
|
775133d54d | ||
|
|
49fba5c18c | ||
|
|
06e473015a | ||
|
|
47cb671979 | ||
|
|
8061a155e8 | ||
|
|
ba1c6433ab | ||
|
|
7daf6066d9 | ||
|
|
f454da6f32 | ||
|
|
259350203c | ||
|
|
675d5ca19a | ||
|
|
33bfa4f658 | ||
|
|
07ea2b9e88 | ||
|
|
372e8bce75 | ||
|
|
762736fc7c | ||
|
|
437d7501de | ||
|
|
dae30608e0 | ||
|
|
1dcb4658ca | ||
|
|
ee07ec84ea | ||
|
|
10826508b5 | ||
|
|
e96c652dd2 | ||
|
|
86b31946b9 | ||
|
|
3a0b54216b | ||
|
|
d23c4dfcfa | ||
|
|
e4cc2fcd63 | ||
|
|
3a0344afbd | ||
|
|
46d0290122 | ||
|
|
d3670a1880 | ||
|
|
91db65ce1e | ||
|
|
72eec1d31c | ||
|
|
d847a3a91e | ||
|
|
ed67c470ab | ||
|
|
4df4e13130 | ||
|
|
e0171b47eb | ||
|
|
3b677ec43f | ||
|
|
5e3072c60d | ||
|
|
56db98fe73 | ||
|
|
95f8d7f44d | ||
|
|
d7cd57b5b6 | ||
|
|
df37966ed5 | ||
|
|
cbd488c1d3 | ||
|
|
ca0d7362a8 | ||
|
|
740005bb2f | ||
|
|
d322fd8b03 | ||
|
|
775501b2f8 | ||
|
|
f4a4895c68 | ||
|
|
577d409615 | ||
|
|
17d937678d | ||
|
|
b063723def | ||
|
|
f7168bbd7d | ||
|
|
fe2627bfd8 | ||
|
|
413eda0b10 | ||
|
|
df0039b629 | ||
|
|
595375ddad | ||
|
|
d4870060c2 | ||
|
|
6c87959b2e | ||
|
|
082815ef9f | ||
|
|
f9eb819cc7 | ||
|
|
6e842cfde5 | ||
|
|
294dcc9ac1 | ||
|
|
1e458e3e3e | ||
|
|
ad15cee84a | ||
|
|
d7b5d3a010 | ||
|
|
9e6b9d1ceb | ||
|
|
10260d108c | ||
|
|
19de0a4943 | ||
|
|
3e3698c4c7 | ||
|
|
f02f0eca8a | ||
|
|
bd74150f6a | ||
|
|
e6ac86e8d5 | ||
|
|
bd810e96eb | ||
|
|
b2c4fadba8 | ||
|
|
4b3948d3e2 | ||
|
|
bbded02729 | ||
|
|
882573e477 | ||
|
|
c2390e0698 | ||
|
|
12a5189576 | ||
|
|
f91c4da56a | ||
|
|
833565de03 | ||
|
|
e058a6c14d | ||
|
|
9006e110ed | ||
|
|
83975c677d | ||
|
|
d0364e6963 | ||
|
|
d60245efb9 | ||
|
|
d3d77bb2c8 | ||
|
|
1e7017772a | ||
|
|
a4bea4e7d3 | ||
|
|
b68cc9f1ff | ||
|
|
5913a0e2e7 | ||
|
|
fe09388e21 | ||
|
|
f7f74c8c35 | ||
|
|
6417b55f50 | ||
|
|
4e17182fc2 | ||
|
|
f1f5394370 | ||
|
|
98d1c04c4f | ||
|
|
20d9fff987 | ||
|
|
30ad3801d2 | ||
|
|
33f5d6a065 | ||
|
|
35ac87bf37 | ||
|
|
31e946a11d | ||
|
|
24db4f5af9 | ||
|
|
ad43bf7eda | ||
|
|
6ea10937d2 | ||
|
|
484adb3cfa | ||
|
|
87df76637b | ||
|
|
74c702a5ab | ||
|
|
9b59c68737 | ||
|
|
3e5b4640d6 | ||
|
|
9f52da1a4d | ||
|
|
2f2aa585bd | ||
|
|
b3ae5b8da2 | ||
|
|
57a283d88c | ||
|
|
a2740f12a8 | ||
|
|
90dd7fa894 | ||
|
|
8be760ac12 | ||
|
|
0a36f2a9f9 | ||
|
|
244226b4da | ||
|
|
a94adda138 | ||
|
|
70ec2c5954 | ||
|
|
587efac57d | ||
|
|
38457f27fb | ||
|
|
6aba69326e | ||
|
|
0f38780115 | ||
|
|
9d4c9b7c63 | ||
|
|
db44dbe561 | ||
|
|
b79b8a879b | ||
|
|
397cc2d7cc | ||
|
|
327e12ad23 | ||
|
|
3171b69984 | ||
|
|
c6b2978f6e | ||
|
|
40e30d9fa6 | ||
|
|
876aab708e | ||
|
|
d4d51e0e20 | ||
|
|
32ed28355f | ||
|
|
0eb5bb700a | ||
|
|
8e4f687c50 | ||
|
|
e318a2f1ca | ||
|
|
ad059f55d4 | ||
|
|
7fba0afc91 | ||
|
|
6570e907a4 | ||
|
|
60a9bddbf1 | ||
|
|
9d3afb37ca | ||
|
|
0b80f0556c | ||
|
|
0361ee6a8b | ||
|
|
7262dc51be | ||
|
|
448fd92f6d | ||
|
|
98fdfb5cfb | ||
|
|
b8d39bec5f | ||
|
|
836308074e | ||
|
|
8150a8fca7 | ||
|
|
cce0d818cf | ||
|
|
7ca5eaab97 | ||
|
|
f12c230689 | ||
|
|
8c0f8e7113 | ||
|
|
cc8a89cdf7 | ||
|
|
6c79f4baeb | ||
|
|
85ac5a79c7 | ||
|
|
b904fbe066 | ||
|
|
0d7d3f4221 | ||
|
|
f291a76135 | ||
|
|
bbde5fab52 | ||
|
|
7101589f8d | ||
|
|
31b2b4d500 | ||
|
|
b7b8367234 | ||
|
|
94a170b0de | ||
|
|
06cd47807c | ||
|
|
23b02cbc74 | ||
|
|
069be90768 | ||
|
|
43d50ce021 | ||
|
|
51085c6480 | ||
|
|
ce5d702c46 | ||
|
|
8569c6be53 | ||
|
|
79116d8c4b | ||
|
|
180ac5ad4d | ||
|
|
06a363509e | ||
|
|
cbfd1f69ee | ||
|
|
5b7f15f2b1 | ||
|
|
45d89701c3 | ||
|
|
25277f66d6 | ||
|
|
de8e91fec2 | ||
|
|
644a9b96a6 | ||
|
|
31e52a7eeb | ||
|
|
3611a93a3a | ||
|
|
ade154b878 | ||
|
|
611e7f6d17 | ||
|
|
e691e0bf1d | ||
|
|
42cb6519ea | ||
|
|
ad66d2c08a | ||
|
|
239dedba48 | ||
|
|
f51a759e0c | ||
|
|
54335db161 | ||
|
|
6d9d388c73 | ||
|
|
c8aa2d4f59 | ||
|
|
947b654d14 | ||
|
|
57d03faa98 | ||
|
|
4d71c4a83a | ||
|
|
af7f2b75b3 | ||
|
|
8901daa517 | ||
|
|
1279b4008f | ||
|
|
69fa1e1018 | ||
|
|
914aadcc4b | ||
|
|
299cf4f45a | ||
|
|
abe15df3c3 | ||
|
|
45bee40955 | ||
|
|
2ad99bf5cd | ||
|
|
523844a774 | ||
|
|
9dbb9deff0 | ||
|
|
afa771dcac | ||
|
|
bf2e9f0781 | ||
|
|
5df6fc4ca0 | ||
|
|
894003c663 | ||
|
|
76e7ca8ac0 | ||
|
|
bc6ff536df | ||
|
|
2b16c5baf1 | ||
|
|
2f8dd177d3 | ||
|
|
46c345401f | ||
|
|
567f4f5ab4 | ||
|
|
1e8814838f | ||
|
|
9dd7e3f5f8 | ||
|
|
158180d32c | ||
|
|
d55beba866 | ||
|
|
2da03248de | ||
|
|
0340784d57 | ||
|
|
d3162a67b4 | ||
|
|
df17fc4f2a | ||
|
|
049a4c514e | ||
|
|
222d09f474 | ||
|
|
b55ec59d37 | ||
|
|
12ad3c6ca6 | ||
|
|
bd3addf6f3 | ||
|
|
2f9908eda8 | ||
|
|
052bdb4978 | ||
|
|
e39a40d817 | ||
|
|
1b4bc1ca2e | ||
|
|
cd7e1be7cb | ||
|
|
f85b9ddf8b | ||
|
|
a15ca672a1 | ||
|
|
2ad192fce7 | ||
|
|
930de8196a | ||
|
|
d3f59d8458 | ||
|
|
8780803f2d | ||
|
|
8665083d51 | ||
|
|
094f2712b9 | ||
|
|
73e449c3ef | ||
|
|
66a93d5dfb | ||
|
|
3c7e197afe | ||
|
|
98fb48de18 | ||
|
|
2e6af36f77 | ||
|
|
3d5f8b6d2b | ||
|
|
9fa8838b0d | ||
|
|
1dd3775dde | ||
|
|
bbfea75857 | ||
|
|
78a9b3b18f | ||
|
|
aff51ae6f3 | ||
|
|
9b85d7bead | ||
|
|
4dba1bc22e | ||
|
|
a7a16c1335 | ||
|
|
e9180d38e5 | ||
|
|
dbcc3a11e5 | ||
|
|
368a7c91aa | ||
|
|
75a7f4e5ba | ||
|
|
7876f6e670 | ||
|
|
3bc51c70d5 | ||
|
|
6ce859d955 | ||
|
|
f5de0995d6 | ||
|
|
fa3e52fa9e | ||
|
|
a1f6a28a1c | ||
|
|
47aaadf587 | ||
|
|
c5d7ab5b48 | ||
|
|
9e8b0a6b8a | ||
|
|
62e07b1cfd | ||
|
|
439dcfe21e | ||
|
|
dec1c302fa | ||
|
|
163cdce23d | ||
|
|
480f708934 | ||
|
|
3bea683c78 | ||
|
|
281c6548c0 | ||
|
|
d81aa3758e | ||
|
|
a5166c6834 | ||
|
|
90f9ff3961 | ||
|
|
611193a139 | ||
|
|
e1aed49d40 | ||
|
|
0ece06dde5 | ||
|
|
663afab2c2 | ||
|
|
f131b5135a | ||
|
|
8c7b9fbbdd | ||
|
|
ac3a1d1b77 | ||
|
|
97c62bafc6 | ||
|
|
7806639749 | ||
|
|
539433f977 | ||
|
|
682c76ffbd | ||
|
|
9188db85aa | ||
|
|
31c7b13278 | ||
|
|
c99fcf0420 | ||
|
|
39d631bbb7 | ||
|
|
c1b034b55d | ||
|
|
484d4c3e68 | ||
|
|
5770ebdba8 | ||
|
|
ecd41aed3f | ||
|
|
061f12df59 | ||
|
|
f45964ec1e | ||
|
|
1eab64ace3 | ||
|
|
264e054707 | ||
|
|
b82c426285 | ||
|
|
da6fb87ebe | ||
|
|
d18325cab7 | ||
|
|
ed8cfe3400 | ||
|
|
4f3a2952d6 | ||
|
|
b45b00fb8e | ||
|
|
c5a2b8c66d | ||
|
|
385df8c7ed | ||
|
|
cc44f54637 | ||
|
|
79d1844020 | ||
|
|
237d1b8d04 | ||
|
|
2bcfc139c3 | ||
|
|
75a5513f9c | ||
|
|
f80f30e964 | ||
|
|
8c472c217b | ||
|
|
14f6511b60 | ||
|
|
ecf37a52a7 | ||
|
|
babd036f2a | ||
|
|
37b36997ad | ||
|
|
b6361fc3f0 | ||
|
|
4afe80b368 | ||
|
|
43cf1087cb | ||
|
|
11d184ddae | ||
|
|
3983f979b7 | ||
|
|
76191eb3a1 | ||
|
|
a5c4578986 | ||
|
|
9abfd110cc | ||
|
|
d0b33b2c06 | ||
|
|
b2aff3432c | ||
|
|
d23b9229db | ||
|
|
e7086d5b1a | ||
|
|
949549b9b1 | ||
|
|
0fd0122400 | ||
|
|
e578e55e7b | ||
|
|
72461af522 | ||
|
|
ecba1aa779 | ||
|
|
52df787444 | ||
|
|
adf650cc83 | ||
|
|
138e810f9c | ||
|
|
84c53bdfde | ||
|
|
f51552508d | ||
|
|
16f29bcba0 | ||
|
|
7a16aaf0a1 | ||
|
|
83e827e0b4 | ||
|
|
d76bdb80c6 | ||
|
|
c81cf7e17b | ||
|
|
54f74f8bdb | ||
|
|
200b71dca5 | ||
|
|
3df41373be | ||
|
|
1212b92bf1 | ||
|
|
d762ff329a | ||
|
|
9f31d49b7f | ||
|
|
9098955a77 | ||
|
|
65dda2d1fb | ||
|
|
c1b5d09196 | ||
|
|
be3934ee17 | ||
|
|
90b6c32549 | ||
|
|
4db73de307 | ||
|
|
8c85ecdb56 | ||
|
|
ae495d3f97 | ||
|
|
20fc6c130d | ||
|
|
3938d91c75 | ||
|
|
b155a0e1b9 | ||
|
|
7e34a2ed07 | ||
|
|
e5dc72812a | ||
|
|
7d1b7f97f0 | ||
|
|
6702314998 | ||
|
|
90c8ea48e2 | ||
|
|
7c3b4aa3c6 | ||
|
|
42b1be58f3 | ||
|
|
e902bfc502 | ||
|
|
12adc39383 | ||
|
|
6feed72a06 | ||
|
|
9f1b0a445e | ||
|
|
31523c240b | ||
|
|
6bbf1a5c47 | ||
|
|
3e5febec04 | ||
|
|
d6e02174d8 | ||
|
|
a91801dcbe | ||
|
|
30ed0907f3 | ||
|
|
9ec1e4e8c3 | ||
|
|
304a06f61d | ||
|
|
756a3ab4bf | ||
|
|
98caad8a6a | ||
|
|
b6c55ab68d | ||
|
|
499abe4fca | ||
|
|
ca8e0bc716 | ||
|
|
d7cd2f482e | ||
|
|
c195854260 | ||
|
|
e0fe418b3a | ||
|
|
06c9ec2d73 | ||
|
|
77a5de2fc9 | ||
|
|
207640bcd1 | ||
|
|
18155535cf | ||
|
|
3cc31cf9b5 | ||
|
|
30435e8123 | ||
|
|
677eeaf5a2 | ||
|
|
faf7c29900 | ||
|
|
eeade9c19f | ||
|
|
d002f60272 | ||
|
|
b64952b71f | ||
|
|
98beb7d742 | ||
|
|
8193b2ffc7 | ||
|
|
115873d287 | ||
|
|
44a0d84c24 | ||
|
|
63efda734c | ||
|
|
979f09b30d | ||
|
|
d5fd45bb41 | ||
|
|
46ba3f8b60 | ||
|
|
22de6d29a4 | ||
|
|
644e8f99bc | ||
|
|
e8a8a8fda2 | ||
|
|
a562d9517b | ||
|
|
19ab9027be | ||
|
|
1a7a90a307 | ||
|
|
4768283500 | ||
|
|
583fea65b6 | ||
|
|
013ddcf034 | ||
|
|
beb599c163 | ||
|
|
fbc7c85af1 | ||
|
|
4a51c50e91 | ||
|
|
edaa45cb6b | ||
|
|
aea973e11e | ||
|
|
b9f859a3d2 | ||
|
|
84c715aa05 | ||
|
|
7c90d80bab | ||
|
|
dc2ed1c61a | ||
|
|
6844f8b258 | ||
|
|
4df871499b | ||
|
|
480ec56f94 | ||
|
|
0564b75187 | ||
|
|
3cfb1d43ba | ||
|
|
3f36ff11f7 | ||
|
|
3c13cc2565 | ||
|
|
62ca5184a3 | ||
|
|
bba3e8008f | ||
|
|
79b034d115 | ||
|
|
da650b63e5 | ||
|
|
2ef7719b02 | ||
|
|
0864965d2c | ||
|
|
1790ff085b | ||
|
|
e6fe2cd5d7 | ||
|
|
e2993ee371 | ||
|
|
7422627d8c | ||
|
|
34ab95212c | ||
|
|
63cfebbef3 | ||
|
|
8096e61133 | ||
|
|
d061d5ed3e | ||
|
|
4880c5c771 | ||
|
|
eda5791c28 | ||
|
|
b1ebfd3a56 | ||
|
|
845ab7b000 | ||
|
|
b4219e544d | ||
|
|
96346219b3 | ||
|
|
8888fcb038 | ||
|
|
a50ea7d56d | ||
|
|
db8c40b23e | ||
|
|
47f26b001b | ||
|
|
573637b89e | ||
|
|
0aa7a2ac84 | ||
|
|
0391141936 | ||
|
|
53df42c890 | ||
|
|
90d4bdec2e | ||
|
|
a98008aff0 | ||
|
|
20887e06c2 | ||
|
|
ddee85e6ae | ||
|
|
beddf6ef3e | ||
|
|
4008b451c6 | ||
|
|
165c0f0665 | ||
|
|
a8ff128ca4 | ||
|
|
66e3ae951f | ||
|
|
ac5ba51ec0 | ||
|
|
91006dfd2e | ||
|
|
9553e7710c | ||
|
|
c11f31f5d3 | ||
|
|
71c1e872ed | ||
|
|
80df2009ac | ||
|
|
0175a17908 | ||
|
|
02151d0afa | ||
|
|
b20b6f3c18 | ||
|
|
39c31ef46c | ||
|
|
40cfea870b | ||
|
|
69c073a378 | ||
|
|
76eeebcece | ||
|
|
d2741725f7 | ||
|
|
0d39b59875 | ||
|
|
b3ddc3cdf2 | ||
|
|
df1a738a97 | ||
|
|
ac8d216aa9 | ||
|
|
5a14a69cc7 | ||
|
|
719579e330 | ||
|
|
aa87073bb5 | ||
|
|
8560c2257d | ||
|
|
8f58e2ddcf | ||
|
|
a2b1075eea | ||
|
|
1e0b5671eb | ||
|
|
0ca46ae273 | ||
|
|
4fc0d8bf2a | ||
|
|
b821021bd0 | ||
|
|
e4a2553ff1 | ||
|
|
3d762c322d | ||
|
|
f3ea262d68 | ||
|
|
0c9b9e68d0 | ||
|
|
c9e4197f58 | ||
|
|
74d09e78ab | ||
|
|
efce54515e | ||
|
|
791664f4d1 | ||
|
|
5d196aa6fa | ||
|
|
18910412d5 | ||
|
|
b95d0f4743 | ||
|
|
78800993e2 | ||
|
|
bd81ab9e92 | ||
|
|
e5dc0eeb3a | ||
|
|
f8a2d530d6 | ||
|
|
595d60fb54 | ||
|
|
fd0074a32f | ||
|
|
1ef3379d5a | ||
|
|
bb7985f333 | ||
|
|
850566bbbc | ||
|
|
f5000ecb88 | ||
|
|
82587cd4b5 | ||
|
|
c31ed67608 | ||
|
|
600afe84d9 | ||
|
|
52cfbea82b | ||
|
|
d9b51a409d | ||
|
|
dcc05a61ae | ||
|
|
d6e03874ff | ||
|
|
5236b71cb6 | ||
|
|
1cccac11e6 | ||
|
|
4a6f5c8fba | ||
|
|
6638ae6c9d | ||
|
|
b2924909e0 | ||
|
|
8c196e268e | ||
|
|
cd605abd5e | ||
|
|
608d5bbd92 | ||
|
|
25a66fdd20 | ||
|
|
a4c8c2a456 | ||
|
|
7012533fec | ||
|
|
87652f2b2f | ||
|
|
abc52964d2 | ||
|
|
79eb04067d | ||
|
|
b4f9450a7a | ||
|
|
d543085ac5 | ||
|
|
3ea340077d | ||
|
|
b022413a1f | ||
|
|
97fbfe52ce | ||
|
|
c7cf9db996 | ||
|
|
bc839185e4 | ||
|
|
1606ebbe55 | ||
|
|
1918c45b96 | ||
|
|
a60865a8b3 | ||
|
|
7e55a51587 | ||
|
|
3cdf6e3a46 | ||
|
|
dc46951462 | ||
|
|
fab404705a | ||
|
|
1d3da80c64 | ||
|
|
b767fdac38 | ||
|
|
233db4dd56 | ||
|
|
28f715f800 | ||
|
|
01a88f3532 | ||
|
|
6a5ad523a8 | ||
|
|
57da8fad9b | ||
|
|
8422bc7386 | ||
|
|
627c80b09a | ||
|
|
9227e4174d | ||
|
|
f0743d993a | ||
|
|
18230ecb9a | ||
|
|
b5e74202aa | ||
|
|
b25d20b665 | ||
|
|
b97ed96dcd | ||
|
|
4f8cc0598d | ||
|
|
45c2dd52c8 | ||
|
|
cc3f901598 | ||
|
|
ebc7322e00 | ||
|
|
426b5e9fee | ||
|
|
7f60474aa9 | ||
|
|
20f8cdb588 | ||
|
|
5323b6e4fc | ||
|
|
54aa1852bc | ||
|
|
fb7510c7b6 | ||
|
|
68417f40b4 | ||
|
|
01254dcdb6 | ||
|
|
0f672e1599 | ||
|
|
ffd2a7094d | ||
|
|
0bc3a849ce | ||
|
|
3aa2915ab6 | ||
|
|
24c1fe7138 | ||
|
|
6ee5f59da8 | ||
|
|
2b0fcce85c | ||
|
|
08910f8d79 | ||
|
|
b9b6498125 | ||
|
|
e903772ee0 | ||
|
|
126394d157 | ||
|
|
2a77d24d44 | ||
|
|
30c84ce58c | ||
|
|
1fe9b401e9 | ||
|
|
f938be656b | ||
|
|
8d263af94a | ||
|
|
1f9e0211f3 | ||
|
|
5c1201fa88 | ||
|
|
a9356e65e2 | ||
|
|
e91706c485 | ||
|
|
6470d18a8f | ||
|
|
61e1a9d568 | ||
|
|
9c5a38abbc | ||
|
|
3c87ae6461 | ||
|
|
13ba200d9f | ||
|
|
7111a2817d | ||
|
|
5783527457 | ||
|
|
f746f180aa | ||
|
|
f7a0036183 | ||
|
|
c259666e69 | ||
|
|
e25bf1776c | ||
|
|
7b336cee65 | ||
|
|
d064bf036c | ||
|
|
dced2e3129 | ||
|
|
623664b430 | ||
|
|
c7507e3dbb | ||
|
|
9c9a9044cc | ||
|
|
3b3b26e991 | ||
|
|
f83afe4a9d | ||
|
|
4780328409 | ||
|
|
0d67bd281b | ||
|
|
0f6652d313 | ||
|
|
2459f71c8b | ||
|
|
a9cb3177cc | ||
|
|
0af7268577 | ||
|
|
fd508b84b8 | ||
|
|
2de9456047 | ||
|
|
9bea201d81 | ||
|
|
b3a7c7a3e0 | ||
|
|
9926a4a1fc | ||
|
|
cad3a3a238 | ||
|
|
53bbe94d3b | ||
|
|
804cbf0fe4 | ||
|
|
ef3c321002 | ||
|
|
077cdf5a54 | ||
|
|
d8dde7fb5e | ||
|
|
9554add7ab | ||
|
|
3e31ef1c27 | ||
|
|
79e66cb7f1 | ||
|
|
b36cd36fd2 | ||
|
|
3f09caa366 | ||
|
|
feccb4786a | ||
|
|
876814e92e | ||
|
|
c1780e08ec | ||
|
|
793bafac1d | ||
|
|
ba4f178e4b | ||
|
|
c3bc4a4f80 | ||
|
|
b9edb1382d | ||
|
|
7adbaca9ab | ||
|
|
574461604d | ||
|
|
9f687bccb7 | ||
|
|
05c0072c6b | ||
|
|
d70a3f562b | ||
|
|
244fad601f | ||
|
|
10b9804ef8 | ||
|
|
3f20431334 | ||
|
|
a201e133de | ||
|
|
a220bf394f | ||
|
|
c8780dcb7c | ||
|
|
4769070af0 | ||
|
|
0853258f92 | ||
|
|
7f0efa20fa | ||
|
|
308795393a | ||
|
|
4ce6a36b12 | ||
|
|
14e5afa54d | ||
|
|
7ee9296bea | ||
|
|
fc1eaf3287 | ||
|
|
fd7854de7e | ||
|
|
0d4441e2cf | ||
|
|
8dabdb64ea | ||
|
|
cb62c52255 | ||
|
|
668b122632 | ||
|
|
c50c815e81 | ||
|
|
429850531a | ||
|
|
14d53ff11e | ||
|
|
79348cfc12 | ||
|
|
e4c7987cf0 | ||
|
|
d9e25e40f6 | ||
|
|
465d3c53cb | ||
|
|
17cc4e20e7 | ||
|
|
dabd74ac44 | ||
|
|
5fed29ed57 | ||
|
|
703c4b5f83 | ||
|
|
43f76aec9a | ||
|
|
6b3957af9a | ||
|
|
c0b57f2f54 | ||
|
|
3bcc648f07 | ||
|
|
4120cb5eef | ||
|
|
26544610ff | ||
|
|
aea50cd647 | ||
|
|
fcbfb49ef7 | ||
|
|
28fd666fa6 | ||
|
|
655e596106 | ||
|
|
fbde89eeb9 | ||
|
|
a2194300cc | ||
|
|
e4e9271fbe | ||
|
|
62cebcf64d | ||
|
|
b2956fd544 | ||
|
|
436cb0f544 | ||
|
|
e1bbb85e3e | ||
|
|
99b67d56e3 | ||
|
|
9993b4f13d | ||
|
|
b630259d21 | ||
|
|
ec276ef415 | ||
|
|
810cfb6ea5 | ||
|
|
1989fe923a | ||
|
|
7af76cfa75 | ||
|
|
5008b98b05 | ||
|
|
48ea979f5b | ||
|
|
37f357b4e8 | ||
|
|
c537b130fe | ||
|
|
8eaaef42de | ||
|
|
1db2f68b3d | ||
|
|
88ce098f55 | ||
|
|
aea65f1ce0 | ||
|
|
c38d795db5 | ||
|
|
e21af924d8 | ||
|
|
64cc650e78 | ||
|
|
1ac8c9e434 | ||
|
|
438dc96334 | ||
|
|
9eff9783fc | ||
|
|
8b3fc51374 | ||
|
|
efc21d5e48 | ||
|
|
3f5c035329 | ||
|
|
f4e8788d29 | ||
|
|
93dcc67e63 | ||
|
|
a9c92f114f | ||
|
|
3e59fba46a | ||
|
|
1d990de7e7 | ||
|
|
78e9d41c83 | ||
|
|
fec55e505d | ||
|
|
488bad3424 | ||
|
|
b70136f46e | ||
|
|
aea90b08d9 | ||
|
|
42aecd649c | ||
|
|
a4ee85232f | ||
|
|
a6bd93036b | ||
|
|
3b6f011c6c | ||
|
|
faef9378e2 | ||
|
|
f1c302826c | ||
|
|
e733ccf47b | ||
|
|
757c7227f8 | ||
|
|
86bfb66990 | ||
|
|
c7ef31fbeb | ||
|
|
28da39f909 | ||
|
|
03fba953ec | ||
|
|
c6edaf70b6 | ||
|
|
96c5703486 | ||
|
|
11a47127cd | ||
|
|
622db45bd5 | ||
|
|
f41abcd881 | ||
|
|
59b9453592 | ||
|
|
ada481e836 | ||
|
|
4011cfbdbc | ||
|
|
cbecb64fef | ||
|
|
6c47ee52eb | ||
|
|
347cd11771 | ||
|
|
4bef5c4667 | ||
|
|
74ec7c8635 | ||
|
|
3b9be7d03f | ||
|
|
e91b96f0bf | ||
|
|
cb49d83e9c | ||
|
|
9b146706fe | ||
|
|
01074e631e | ||
|
|
5af14795af | ||
|
|
64b7cd58b2 | ||
|
|
a3683465d3 | ||
|
|
d2fbc37a1b | ||
|
|
fab913bce5 | ||
|
|
3de65e6ce4 | ||
|
|
d127b97e36 | ||
|
|
d754811d5f | ||
|
|
8a5c248fbb | ||
|
|
fde800e337 | ||
|
|
f9e4c43a48 | ||
|
|
307dd7a704 | ||
|
|
88a9ada626 | ||
|
|
6c5ee0084f | ||
|
|
262fbd7512 | ||
|
|
783a645de0 | ||
|
|
b88a8933ed | ||
|
|
fd8e4a80cd | ||
|
|
e61ead98d9 | ||
|
|
3176291f40 | ||
|
|
bcc328eeb5 | ||
|
|
13ddabf832 | ||
|
|
6ac9b4e7fa | ||
|
|
50a6764eaa | ||
|
|
e6fec870db | ||
|
|
743b534212 | ||
|
|
25fee85f0f | ||
|
|
0cf549e14a | ||
|
|
88d9f82941 | ||
|
|
d96a7707a6 | ||
|
|
43f7fcae36 | ||
|
|
48c8495d3e | ||
|
|
e6dc350cf2 | ||
|
|
5b05ebee28 | ||
|
|
60bf39efd5 | ||
|
|
09cbdda519 | ||
|
|
3ebbe0e4ae | ||
|
|
cc10ca8198 | ||
|
|
f2255e0d27 | ||
|
|
b68289f24a | ||
|
|
18ae46f7a3 | ||
|
|
670fa67ef9 | ||
|
|
654884817a | ||
|
|
f1ea6338c3 | ||
|
|
d16b9c5e8b | ||
|
|
1fe71ee5d3 | ||
|
|
be221ec89d | ||
|
|
d144840384 | ||
|
|
8d2eac55d7 | ||
|
|
a7283bccb3 | ||
|
|
24847366e1 | ||
|
|
095e9c3a31 | ||
|
|
f1e79a7ad6 | ||
|
|
3213741104 | ||
|
|
ddd5e3c46b | ||
|
|
2204ccaed5 | ||
|
|
a4756e953d | ||
|
|
aa58dfb598 | ||
|
|
140726a1ea | ||
|
|
90e373199c | ||
|
|
c0a5ec4e67 | ||
|
|
a77f508b05 | ||
|
|
d2b58f8636 | ||
|
|
c758131fb9 | ||
|
|
4fd80c8d59 | ||
|
|
fcf87ec68c | ||
|
|
1ac9a6ba25 | ||
|
|
3d9feaba0b | ||
|
|
c944ffe558 | ||
|
|
28b3306867 | ||
|
|
87d305da1d | ||
|
|
505fe93d2c | ||
|
|
e08568b3b5 | ||
|
|
a65845e5fa | ||
|
|
da92158f26 | ||
|
|
e65905b4b3 | ||
|
|
17ccc0b995 | ||
|
|
3d99416631 | ||
|
|
53e9a62b6e | ||
|
|
19ad4b6a72 | ||
|
|
701f591c5e | ||
|
|
ac0031b75d | ||
|
|
c7e4cb0c29 | ||
|
|
d040afebfb | ||
|
|
1648d8ac6b | ||
|
|
afee5e28d2 | ||
|
|
4a7195be12 | ||
|
|
923507baeb | ||
|
|
6aacd46b03 | ||
|
|
d93902eaff | ||
|
|
4d5f845b8c | ||
|
|
c9cdc34abc | ||
|
|
737f315b53 | ||
|
|
c91e48127a | ||
|
|
0eb582492f | ||
|
|
74ec0c78b9 | ||
|
|
abee50b90e | ||
|
|
e891790ce0 | ||
|
|
ce847f2b6f | ||
|
|
26f95a3bca | ||
|
|
0950bf609d | ||
|
|
5b939067e1 | ||
|
|
465e7011e5 | ||
|
|
fe79fed793 | ||
|
|
c0b8e901ba | ||
|
|
007b28e2ed | ||
|
|
480deebf02 | ||
|
|
f1045bc099 | ||
|
|
8a7da3192f | ||
|
|
bc312f249e | ||
|
|
a392199048 | ||
|
|
337c4f8f87 | ||
|
|
c517805213 | ||
|
|
049208c095 | ||
|
|
c07f3d9373 | ||
|
|
f39b15f76d | ||
|
|
cb02cdd545 | ||
|
|
3190ea955a | ||
|
|
9dca9e139f | ||
|
|
3adb119dd3 | ||
|
|
13778d96a0 | ||
|
|
121a2e7b87 | ||
|
|
29602d261c | ||
|
|
bf5f916cd4 | ||
|
|
5eabe14982 | ||
|
|
6c59bdf724 | ||
|
|
02e8867588 | ||
|
|
3bca4b3485 | ||
|
|
8f19a9163a | ||
|
|
1a4ad7edb2 | ||
|
|
178c301c5f | ||
|
|
1e2ebf43f9 | ||
|
|
c5597305e0 | ||
|
|
0e06266770 | ||
|
|
4ee61733ec | ||
|
|
dc959e0c0f | ||
|
|
7b8770d203 | ||
|
|
4b53ad8b9d | ||
|
|
1b9db252bc | ||
|
|
3caeb28f4a | ||
|
|
360668492f | ||
|
|
4707a4226a | ||
|
|
ca0aca6fd7 | ||
|
|
9b6796df1c | ||
|
|
1af4d746d3 | ||
|
|
a82290fe8f | ||
|
|
fc377d4b8f | ||
|
|
0ec4940c9a | ||
|
|
acac966cdc | ||
|
|
79984e1c84 | ||
|
|
b66c7c9a52 | ||
|
|
e052b2e27f | ||
|
|
9bfe856f59 | ||
|
|
321263af61 | ||
|
|
37b07b1709 | ||
|
|
72a75fe3bb | ||
|
|
5869016c42 | ||
|
|
e653935288 | ||
|
|
08e974ed48 | ||
|
|
cf0d5afcef | ||
|
|
3a42945fcc | ||
|
|
b8a2f991c1 | ||
|
|
5c359b710a | ||
|
|
4ffe1870d1 | ||
|
|
2f892071d2 | ||
|
|
effcefca34 | ||
|
|
617ba37988 | ||
|
|
addd754eb3 | ||
|
|
f2138ac15e | ||
|
|
f9bd5ad923 | ||
|
|
dd1d24e341 | ||
|
|
7731f1bdc0 | ||
|
|
5270f78ff1 | ||
|
|
34f4e3b145 | ||
|
|
360fa45b3e | ||
|
|
9187eb61ac | ||
|
|
5c311d3eef | ||
|
|
1884f03350 | ||
|
|
333c50f663 | ||
|
|
71e9755d6c | ||
|
|
6cfd1abb59 | ||
|
|
5e7ea24340 | ||
|
|
71d07d8770 | ||
|
|
6dd0f85f67 | ||
|
|
85fbeb644a | ||
|
|
b8a4586b51 | ||
|
|
d51692a62d | ||
|
|
6f6198f719 | ||
|
|
cd86ffd6f8 | ||
|
|
20a06fdb94 | ||
|
|
4bb1165c9d | ||
|
|
3a7a5075de | ||
|
|
f5fa4d8213 | ||
|
|
51d36b5c9e | ||
|
|
2674190bb4 | ||
|
|
ff07021c22 | ||
|
|
d2380dd951 | ||
|
|
a7fb5513e4 | ||
|
|
76f67f096e | ||
|
|
da9870b5e1 | ||
|
|
79a681b88b | ||
|
|
0ed37b82d3 | ||
|
|
fbb1043f58 | ||
|
|
65b9dc09a2 | ||
|
|
6eed85af89 | ||
|
|
4df8c6662f | ||
|
|
d89a92dbad | ||
|
|
4ebee54ffd | ||
|
|
1b6b811108 | ||
|
|
ab0b2282ab | ||
|
|
f02de24470 | ||
|
|
665515da40 | ||
|
|
68fb92c8d5 | ||
|
|
7a20d54847 | ||
|
|
53e12ff376 | ||
|
|
055143f67a | ||
|
|
b4a8f7f180 | ||
|
|
e5d4f37469 | ||
|
|
556fc7a03a | ||
|
|
cc37dfcb01 | ||
|
|
3de75a3315 | ||
|
|
28c2b6ee95 | ||
|
|
1cde846bcf | ||
|
|
ce0dee9afb | ||
|
|
11ae998de0 | ||
|
|
901923457c | ||
|
|
1b28bacb46 | ||
|
|
918989b98a | ||
|
|
372930ce71 | ||
|
|
652dccd2c4 | ||
|
|
90b6a7f012 | ||
|
|
7efe5a4783 | ||
|
|
0300a4d881 | ||
|
|
85c8d026a2 | ||
|
|
c09d61d8bc | ||
|
|
f482bf0aa1 | ||
|
|
2eb328104a | ||
|
|
91a240c072 | ||
|
|
dd70011fd9 | ||
|
|
954be8443c | ||
|
|
cae5553f1a | ||
|
|
fc7fabe544 | ||
|
|
d74499c09d | ||
|
|
29858d0db0 | ||
|
|
77732c7bb1 | ||
|
|
dde79fc9bf | ||
|
|
4ee43b8afc |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: frontend-code-review
|
||||
description: Use only when the user explicitly requests a review or audit of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, or backend-only code.
|
||||
description: Use only when the user explicitly requests a bug, regression, correctness, security, accessibility, performance, or test-quality review of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for architecture-oriented evaluation of component ownership, state graphs, props, boundaries, or refactoring design unless concrete defects are also requested; do not use for implementation-only requests, diagnosis without review intent, or backend-only code.
|
||||
---
|
||||
|
||||
# Frontend Code Review
|
||||
@@ -11,8 +11,9 @@ Review the requested scope for concrete defects and violations of explicit proje
|
||||
|
||||
1. Establish the review scope from the requested files or current diff.
|
||||
2. Read the changed lines, their behavior owner, and the nearest scoped `AGENTS.md`.
|
||||
3. Trace public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
|
||||
4. Report findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. Explicit team conventions are contracts: establish their scope and exceptions, and do not invent user impact to justify a convention finding.
|
||||
3. For a named component or a diff that changes component ownership, props, or state, read the component-architecture rule pack and complete its Root-To-Leaf State And Props Audit before drawing conclusions.
|
||||
4. Trace other public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
|
||||
5. Report findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. Explicit team conventions are contracts: establish their scope and exceptions, and do not invent user impact to justify a convention finding.
|
||||
|
||||
## Rule Routing
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ Use the canonical reference for the changed concern. These links share rules; th
|
||||
| Effects, navigation, memoization, and subscriptions | [Runtime] |
|
||||
| Hotkeys, focus, and secondary surfaces | [Interactions] |
|
||||
|
||||
## Root-To-Leaf State And Props Audit
|
||||
|
||||
For a file-focused component review, use the named component as the root. For a pending diff, use each changed page, feature entrypoint, or nearest behavior owner whose state or props contract changed.
|
||||
|
||||
1. Build the locally owned rendered component tree from each root to every leaf. Follow imported feature components, conditional branches, lists, portals, dialogs, drawers, and popovers. Stop at third-party or Dify UI primitives and explicit stable public feature boundaries, but record the props passed across that final edge.
|
||||
2. At every component, inventory state sources: React state/reducers, form state, URL state, context/store/Jotai reads and writes, queries, mutations, refs that hold workflow state, and custom-hook results.
|
||||
3. For every state value, derived fact, and handler, record its consuming branches and required lifetime, including persistence across descendant unmounts. Assess its placement against [Ownership] and [State], including their coordination and persistence exceptions.
|
||||
4. Inspect every props edge. Mark values that are read by the child, forwarded unchanged, renamed, recomputed, mirrored into local state, or paired with lifecycle fields such as `data/pending/error/retry` and `open/onOpenChange`.
|
||||
5. Trace what each forwarding parent actually owns before reporting an unnecessary intermediary. Apply [Ownership] to distinguish required coordination or persistence from merely redistributing workflow fields, and [State] to determine whether a feature graph is warranted.
|
||||
6. Remove unused or redundant props at their source. Do not accept replacing many props with a props bag, context, or hook result unless that abstraction becomes the real owner and gives consumers a narrower contract.
|
||||
|
||||
Review the complete paths before concluding that a state or prop is necessary. When several edges share one ownership defect, report one finding at the highest incorrect owner and include a representative path such as `Root -> Section -> Leaf`; do not emit a duplicate finding for every child.
|
||||
|
||||
## Apply Rules In Their Actual Scope
|
||||
|
||||
Explicit team conventions are reviewable contracts, including module organization and public API boundaries. Check the documented exception before reporting a violation. Do not infer an exception solely because the code appears to work, or invent a user-facing failure for a convention finding.
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: Use when implementing or refactoring React/TypeScript components and the task requires decisions about component ownership, feature boundaries, state, data flow, effects, or interaction ownership. Do not use for review-only requests, test-only work, copy-only edits, or styling-only changes.
|
||||
description: Use when implementing, refactoring, or architecture-reviewing React/TypeScript components whose ownership, state lifetimes, data flow, props, feature boundaries, effects, or interaction surfaces need redesign. Do not use for bug, regression, security, or accessibility audits; test-only work; copy-only edits; or styling-only changes.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
# React Component Architecture
|
||||
|
||||
Use this skill to route component architecture decisions to its bundled references. Read only the references required by the current change.
|
||||
Use this skill to make component ownership and lifecycle explicit before implementation. Classify every stateful value on three independent dimensions:
|
||||
|
||||
1. **Owner/source:** a component, parent boundary, URL/framework API, TanStack Query, or feature workflow.
|
||||
2. **Graph role:** primitive input, query/mutation node, derived business fact, or workflow command.
|
||||
3. **Lifecycle:** instance isolation, persistence, and reset requirements.
|
||||
|
||||
Do not force one label to answer all three dimensions.
|
||||
|
||||
Keep display state, submit-only fields, and owner-local queries or mutations with the component or DOM that owns their complete lifecycle. A value enters the feature state graph only when it crosses an ownership boundary, participates in shared derivation or coordination, or must outlive/reset independently of its current owner. Driving an owner-local query or command alone is not sufficient. In this repository, use feature-local Jotai as the default representation once a value enters that graph.
|
||||
|
||||
## Operating Modes
|
||||
|
||||
Choose the mode from the user's requested outcome:
|
||||
|
||||
- **Architecture audit:** Perform a read-only, root-to-leaf analysis and propose the target ownership model. Read [`references/audit.md`][audit] and [`references/ownership.md`][ownership].
|
||||
- **Refactoring design:** Perform the architecture audit, then provide target contracts, migration slices, and a verification strategy without modifying code. Read [`references/audit.md`][audit] and [`references/ownership.md`][ownership].
|
||||
- **Implementation:** Establish the affected owner map, state graph, props edges, and reset boundaries before editing. Implement one coherent vertical slice at a time, then run the final audit in [`references/audit.md`][audit]. Read [`references/ownership.md`][ownership].
|
||||
- **Bug or regression review:** Use the frontend code-review workflow unless the user explicitly requests this skill's architecture model as an additional lens.
|
||||
|
||||
## Topic Routing
|
||||
|
||||
After selecting the operating mode, read only the topic references required by the change:
|
||||
|
||||
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
|
||||
- Dify UI wrappers or primitive state styling: read the package's [Public API authoring] and [Styling] contracts.
|
||||
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
|
||||
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
|
||||
- Jotai, graph-entry decisions, form drafts, shared client state, route identity, URL state, isolation/reset, or persistence: read [`references/state.md`][state].
|
||||
- Generated contracts, nullable API data, Query, mutations, SSR, authentication, or workspace state: read [`references/data.md`][data].
|
||||
- A feature graph that combines Jotai isolation with TanStack Query atoms or commands: read both [`references/state.md`][state] and [`references/data.md`][data].
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or session state.
|
||||
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
|
||||
|
||||
## Common Workflow
|
||||
|
||||
Follow this order so component splitting does not precede ownership decisions:
|
||||
|
||||
1. **Boundary and evidence:** identify the route, tab, workflow, or action surface that owns the behavior and lifetime. Inspect nearby implementation, tests, sibling state files, and established feature boundaries.
|
||||
2. **Current model:** identify generated data contracts and authoritative external inputs, then inventory state, queries, mutations, derived facts, commands, refs, and meaningful props edges.
|
||||
3. **Target graph and owners:** decide explicitly which values remain local and which enter the feature graph. Define authoritative bridges, graph nodes, component owners, and scope/reset boundaries before creating files or wiring components.
|
||||
4. **Re-cut the tree:** treat current files as evidence rather than target constraints. Keep, split, merge, remove, rename, promote, or demote components based on ownership, lifecycle, behavior, and independently changing visual regions.
|
||||
5. **Contracts and interactions:** place data, loading, empty, error, and handlers at the lowest real consumer. Cross true owner boundaries only with stable identity, a small immutable snapshot, placement, or a named command. Give forms and secondary surfaces explicit lifecycle owners.
|
||||
6. **Finish and verify:** remove copied state and unnecessary Effects, wrappers, memoization, or nullable coercion. Rerun the final owner, graph, props-edge, and reset audit, then verify observable behavior at the narrowest sufficient boundary.
|
||||
|
||||
## Scope And Verification
|
||||
|
||||
Identify the behavior owner, state lifetime, and public contract from the nearby implementation and relevant references. Keep the change within that vertical slice unless the contract requires changes elsewhere. Verify the changed behavior and complete the owning package's required checks. For Web, read `web/docs/test.md` for test work and `web/docs/lint.md` for static checks. Dify UI verification is owned by `packages/dify-ui/docs/testing.md`.
|
||||
In architecture-audit and refactoring-design modes, do not modify code. In implementation mode, complete equivalent ownership fixes inside the audited feature when the target contract depends on them, but do not expand into unrelated features. Incremental slices may retain documented temporary edges; the final requested slice must pass the completion gate in [`references/audit.md`][audit].
|
||||
|
||||
Run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
|
||||
|
||||
[audit]: references/audit.md
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
|
||||
64
.agents/skills/how-to-write-component/references/audit.md
Normal file
64
.agents/skills/how-to-write-component/references/audit.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Component Architecture Audit
|
||||
|
||||
Read this document for architecture audits, refactoring designs, and the final ownership verification after an implementation refactor.
|
||||
|
||||
## Audit Procedure
|
||||
|
||||
For a named component, use it as the audit root. For a refactoring request, use the nearest route, page, feature entrypoint, or behavior owner whose state contract is changing.
|
||||
|
||||
1. Build the complete locally owned rendered component tree from the root to every leaf. Follow imported feature components, conditional branches, lists, portals, dialogs, drawers, popovers, and secondary surfaces. Stop at third-party primitives, Dify UI primitives, or an explicit stable feature boundary, but record the props passed across the final edge.
|
||||
2. At every component, inventory React state and reducers, form and uncontrolled DOM state, URL and route state, Context/Jotai/store reads and writes, queries, mutations, refs that hold workflow or lifecycle state, custom-hook results, render-time derived facts, and workflow commands or handlers.
|
||||
3. For every value, record its authoritative owner/source, graph role, declaring component, consuming branches, lifetime and reset/isolation requirements, and whether it is local, shared, forwarded, mirrored, or persisted.
|
||||
4. Inspect every meaningful props edge. Mark each prop as consumed, forwarded unchanged, renamed, recomputed, mirrored into local state, or paired with lifecycle fields such as `data/pending/error/retry` and `open/onOpenChange`.
|
||||
5. Draw the current state graph in dependency order and assess ownership only after the complete paths are mapped. Group related problems at the highest incorrect owner instead of repeating the same issue for every descendant.
|
||||
6. Give every current local component an explicit target disposition: keep, split, merge, remove, rename, promote to an owner, or demote to presentation. Map every current state or workflow owner to a target component and reset boundary.
|
||||
7. Redraw the target rendered tree independently of the current file layout. Split only when state lifetime, interaction lifecycle, behavior ownership, or an independently changing visual region establishes a real boundary. Merge or remove components that only forward, rename, or obscure a single owner's contract.
|
||||
8. For refactoring design or implementation planning, define ordered migration slices with explicit state-file changes, component moves/splits/merges, observable verification boundaries, and intentionally temporary props edges.
|
||||
|
||||
A rename, facade wrapper, props bag, Context, or provider around the same switchboard does not count as a boundary redesign. Correctness bugs may illustrate an ownership defect, but architecture-audit mode does not prioritize or severity-rank bug findings.
|
||||
|
||||
## Audit Output
|
||||
|
||||
Cover the following information. Merge sections or omit empty tables when the review is small, but do not omit applicable values, paths, edges, owners, or reset boundaries:
|
||||
|
||||
1. **Review boundary:** root component, locally owned rendered paths, and stopping boundaries.
|
||||
2. **Rendered component tree:** every local branch and secondary surface followed.
|
||||
3. **State inventory:** owner/source, graph role, graph-entry decision, declaration, consumers, and lifetime/reset needs for every stateful value, query, mutation, ref, custom-hook result, derived fact, and command.
|
||||
4. **Props-edge ledger:** every meaningful parent-child edge and how each prop is treated.
|
||||
5. **Current architecture:** state graph, component owners, and ownership assessment.
|
||||
6. **Component dispositions:** the target disposition of every current local component and the destination of every current state/workflow owner.
|
||||
7. **Target architecture:** target rendered tree, state graph, owners, component contracts, and reset boundaries.
|
||||
8. **Migration slices:** required for refactoring design; include them in an audit when they materially clarify how to reach the target.
|
||||
|
||||
Use compact tables when they make full accounting easier to verify:
|
||||
|
||||
| Value | Source/role | Declared at | Consumers | Props path | Lifetime/reset | Recommended owner |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
|
||||
| Edge | Prop | Treatment | Real consumer | Assessment |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
||||
| Current component | Current responsibilities | Target disposition | Target owner(s) | Reason |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
||||
| Target component | Owned state/workflow | Children | Public contract | Reset boundary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
||||
## Implementation Completion Gate
|
||||
|
||||
Repeat the audit against the final code. Do not describe an implementation or refactor as complete until all applicable checks pass:
|
||||
|
||||
- every locally owned rendered path, meaningful props edge, and stateful value is represented in the component tree, inventory, or ledger;
|
||||
- every current local component has a target disposition, and every current state/workflow owner maps to an implemented component and reset boundary;
|
||||
- every multi-consumer graph value has one authoritative bridge or graph node and a named owner;
|
||||
- route or URL identity is read directly by a single consumer or bridged once, not also threaded through descendant props;
|
||||
- query keys, observer objects, `refetch`, loading/error groups, and cache invalidation mechanics do not cross a boundary unless the child owns the query surface;
|
||||
- no page, hook, provider, or Context mainly destructures, renames, and redistributes another state machine;
|
||||
- components consume focused fields, named facts, or domain commands instead of rebuilding shared business conclusions;
|
||||
- query and mutation atoms remain cache-shared, while only resettable workflow primitives or boundary snapshots are scoped;
|
||||
- a feature scope that depends on application state still observes parent/global atom updates; a fresh `Provider` is used only when full-store isolation is an explicit requirement;
|
||||
- scoped-state verification covers parent-store interoperation, sibling-instance isolation when multiple instances are supported, and semantic-identity reset;
|
||||
- every remaining multi-layer prop edge is justified as stable identity, an immutable display snapshot, placement, or a named cross-boundary command;
|
||||
- the final rendered tree, state graph, owner map, contracts, and reset boundaries match the implemented code rather than only the initial design.
|
||||
|
||||
If the user requests incremental commits, a slice may intentionally leave documented edges for a later slice. The final requested slice must still pass this gate.
|
||||
@@ -17,18 +17,24 @@ Read this document when a component consumes generated contracts, nullable API v
|
||||
|
||||
- Use generated options directly with `useQuery(consoleQuery.xxx.queryOptions(...))`, `marketplaceQuery`, or the equivalent generated client.
|
||||
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
|
||||
- Keep owner-local queries in `useQuery` when their pending, error, and data state serve only that component. Promote them to query atoms only when atom input drives them or other graph nodes consume their result.
|
||||
- When a query supplies several sibling owners, shared derived facts, or workflow commands, model it as a graph node with field selectors. Do not keep it in a page and pass its data, query key, observer methods, and status fields back down as a deconstructed query object.
|
||||
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
|
||||
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
|
||||
- For the same logical request, preserve key, input, operation, and result contracts. Reuse exact options only when
|
||||
transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract a helper
|
||||
merely to reuse input construction.
|
||||
- For the same logical request, preserve key, input, operation, and result contracts. Share the exact options between prefetch and render only when transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract option helpers merely to reuse input construction.
|
||||
- In Jotai-backed components, consume field-level selectors or named facts rather than the complete query result unless observer methods such as `refetch` or an infinite-scroll field group are part of that owner's contract.
|
||||
- Keep observer methods inside the component that owns the query surface or behind a named graph command. A parent must not pass raw `refetch`, query keys, or invalidation details merely because a descendant action needs fresh data.
|
||||
- Treat `refetch()` as an explicit execution command: it can run a query whose observer currently has `enabled: false`. Retry and refresh commands must recheck the permission, identity, or availability condition before including conditionally enabled queries.
|
||||
- Cache writes and query-observer notifications may reach React through asynchronous batching, including when `atomWithQuery` observes an existing QueryClient. Tests that call `setQueryData`, invalidate, or complete a refetch must await the resulting DOM fact with `findBy*` or `waitFor`; do not assume the observer rerender is synchronous with the cache call.
|
||||
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
|
||||
|
||||
## Mutations And Cache
|
||||
|
||||
- Use generated `mutationOptions()` directly for owner-local mutations.
|
||||
- Keep a mutation in `useMutation` when pending and error state belong to one dialog or form. Use a mutation atom only when shared workflow orchestration or graph-derived state consumes it; query and mutation atoms remain unscoped.
|
||||
- Do not introduce deprecated `useInvalid` or `useReset` APIs.
|
||||
- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. In oRPC v1, caller options override defaults, including callbacks. Local callbacks may own toast, close, and navigation effects but must preserve shared cache behavior.
|
||||
- Workflow-specific invalidation belongs beside the mutation/command that understands which caches become stale. Construct generated query options or keys from graph identity there instead of receiving keys from a rendering parent.
|
||||
- Prefer `mutate(...)`. Use `mutateAsync(...)` only when Promise composition is required, and catch awaited failures.
|
||||
- Preserve intentional empty values and current list/detail ownership when updating data. Do not add optimistic updates without a verified owner contract.
|
||||
|
||||
|
||||
@@ -21,11 +21,14 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
## Secondary Surfaces
|
||||
|
||||
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
|
||||
- A controlled composite may emit several callbacks synchronously for one gesture, such as changing a mode and then initializing its selection. Every callback sees the same render snapshot. Compose changes through one owner command or functional state update; when later callbacks or immediate side effects require the latest draft before React rerenders, maintain an interaction-owned ref updated in the handlers and clear it at the same semantic reset boundary as the draft.
|
||||
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
|
||||
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
|
||||
- Treat an action surface as a state boundary when its input feeds several sibling menus, dialogs, default values, confirmation messages, or mutation parameters. Inject the smallest stable identity or snapshot once; keep the form draft local unless other owners must react to it.
|
||||
- Use the [overlay contract] to determine content lifetime and preserve the Root's closing lifecycle. Follow [state ownership] for draft placement and semantic identity; portal placement alone does not locate the state owner.
|
||||
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
|
||||
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
|
||||
- Do not merge dialogs with different loading guards, reset policies, or lifetimes into one discriminated `currentDialog` state merely to reduce atom count.
|
||||
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
[state ownership]: state.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Component Ownership And Modules
|
||||
|
||||
Read this document when adding, moving, splitting, or refactoring React components or feature modules.
|
||||
Read this document when auditing, adding, moving, splitting, or refactoring React components or feature modules.
|
||||
|
||||
## Vertical Modules
|
||||
|
||||
@@ -12,12 +12,20 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
|
||||
## Component Ownership
|
||||
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient are
|
||||
acceptable; shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so
|
||||
request-level deduplication needs an identified request-local cache or verified framework or transport owner.
|
||||
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
|
||||
- Before declaring state, a query, or a workflow hook in a page or parent, identify the direct descendant branches that consume each returned value.
|
||||
- If only one branch consumes a value, declare it in the lowest owner in that branch whose mounted lifetime matches the required persistence. The parent may pass stable identity or the smallest boundary input, but must not own child state merely to construct props.
|
||||
- A parent may own a value when it must intentionally survive the local owner's unmount, or when several sibling branches require one live snapshot and the parent genuinely derives or coordinates submission, selection, batch behavior, navigation, lifecycle, loading, or errors. If it only destructures, renames, and forwards fields, it is a switchboard rather than an owner; put the shared workflow in a feature-local state graph so each surface consumes only its named facts and commands. Use a provider only when it becomes the authoritative input, scope, or external-dependency owner.
|
||||
- A provider is a real boundary only when it establishes authoritative input, scope/isolation, or a stable external dependency. A provider that calls a large hook and repackages its return value into Context remains a switchboard.
|
||||
- A page or feature root may wire route identity, providers, layout, navigation, and genuine cross-surface coordination. It must not call a child-specific state or query hook merely to assemble that child's props.
|
||||
- Keep child contracts at the ownership boundary: stable domain identity, a small immutable snapshot, placement options, or named cross-boundary commands. Do not pass an internal state machine as separate `data`, `pending`, `error`, `retry`, `open`, setter, and callback props when the parent does not use them, and do not hide the same fan-out in a props bag or hook result object.
|
||||
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient share that cache. Separate Server Component QueryClients do not, so request-level deduplication needs an identified request-local cache or verified framework or transport owner.
|
||||
- Treat parent input according to what the child boundary does with it:
|
||||
- If the child only renders or performs a light local decision, pass the snapshot as props. It does not become a new state owner.
|
||||
- If the child builds queries, dialogs, mutations, derivations, commands, or a reset lifecycle around a stable identity or snapshot, give that boundary a feature-local state file. This does not by itself require a new module or directory.
|
||||
- Pass stable domain identity or the smallest sufficient action snapshot across boundaries. Do not copy props into atoms merely to avoid passing them, and do not pass raw server data together with separately derived flags for the same concept.
|
||||
- One pass-through layer is acceptable for stable identity and placement. It is not permission to relay workflow state and handlers through an unrelated component.
|
||||
- Route identity may pass once from a framework route into its feature boundary. If multiple descendants, queries, facts, or commands need it, bridge it into the feature graph and stop passing it as props.
|
||||
- A query snapshot may cross once as immutable display input. Query keys, observer methods, retry/loading/error groups, and invalidation mechanics belong to the query surface or feature graph and must not be decomposed into props.
|
||||
- Revisit prop forwarding when intermediate components obscure the behavior owner; keep clear data flow rather than introducing shared state merely to avoid passing props.
|
||||
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
|
||||
- Keep source selection, defaults, validation, dirty checks, and payload shaping beside the workflow that owns submission.
|
||||
|
||||
@@ -8,6 +8,9 @@ Read this document when a change introduces Effects, navigation side effects, me
|
||||
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
|
||||
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
|
||||
- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset.
|
||||
- When external synchronization participates in a feature state graph, prefer a small headless runtime controller that reads and writes focused atoms. Do not hide storage, polling, invalidation, and workflow refs in a giant hook that returns a state-and-handler object for another component or provider to redistribute.
|
||||
- React state is not an immediate mutex for async serializers, debounce pipelines, migrations, or latest-wins queues because state updates do not synchronously change an in-flight closure. Keep renderable progress in state, but use owner-local refs for immediate locks, active operation identities, and queued latest snapshots; update those refs before awaiting work or scheduling the corresponding state update.
|
||||
- A state value and ref may describe one workflow only when their roles are explicit: state renders the external status, while the ref coordinates events between renders. Retire both at the same success, failure, cancellation, identity-change, and unmount boundaries so neither can resurrect stale work.
|
||||
|
||||
## Navigation
|
||||
|
||||
@@ -18,6 +21,7 @@ Read this document when a change introduces Effects, navigation side effects, me
|
||||
## Runtime Cost
|
||||
|
||||
- Move changing state to the smallest consumer before considering memoization. Stable parent content can be lifted and passed as children.
|
||||
- When a broad state owner causes unrelated rendering, move the owner or expose a narrower atom/query selector before adding memoization.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless identity or computation has a demonstrated consumer or measurable cost.
|
||||
- Start independent remote work together and await it near the branch that consumes it. Avoid introducing request waterfalls.
|
||||
- Load heavy optional surfaces on demand when they sit behind a dialog, tab, command, or feature activation.
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
Read this document when a change involves Jotai, form drafts, route identity, shared client state, or local persistence.
|
||||
|
||||
## Classify State On Two Axes
|
||||
|
||||
First identify the owner or source of truth: one component, a parent boundary, URL/framework APIs, TanStack Query, or the feature workflow. Then identify the graph role independently: primitive input, query/mutation node, derived business fact, or workflow command. Finally mark whether any node needs per-instance isolation or reset; this is a lifecycle requirement, not a node type.
|
||||
|
||||
A value does not enter the feature state graph solely because it drives an owner-local query or command. It should enter when it participates in shared workflow state: another owner or graph node consumes it, several consumers need one external-source bridge, it feeds reusable business derivation or coordinated writes, or its persistence/isolation must outlive the current owner. Entering the graph does not transfer ownership from URL APIs or Query cache to Jotai.
|
||||
|
||||
Once a value enters the graph, represent it in a feature-local Jotai state file by default. Do not leave graph inputs and async nodes inside a component or large custom hook while exporting only the final result through props or Context; that hides the dependency graph instead of modeling it.
|
||||
|
||||
Keep values out of the graph when they only affect one component's presentation, are read only at form submission, or are one-off render computations without domain meaning.
|
||||
|
||||
## Choose The Owner
|
||||
|
||||
- Keep synchronous state local when one component owns it: dialog and menu state, confirmations, field drafts, and local selections usually belong to the component or DOM.
|
||||
- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, or a scoped workflow must preserve state across hidden or unmounted steps.
|
||||
- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, a parent input establishes its own state graph, or a scoped workflow must preserve state across hidden or unmounted steps.
|
||||
- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels.
|
||||
- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage.
|
||||
- Use Context for an existing authoritative product boundary or stable dependency injection. Do not introduce Context merely to translate a hook result, query observer, or prop fan-out into another large value object.
|
||||
|
||||
## Forms And Sessions
|
||||
|
||||
@@ -21,19 +32,52 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
|
||||
## Route And URL State
|
||||
|
||||
- Treat `useParams`, route arguments, and `nuqs` as the owners of URL identity and updates.
|
||||
- Hydrate a primitive atom at the route or surface boundary only when query atoms or shared derived atoms require route identity. Keep URL writes in route and query-state APIs.
|
||||
- A single consumer should read the owner hook directly. When several consumers, query atoms, or shared derived atoms require route identity, first match the bridge lifetime to the route or surface instance. An unscoped primitive is safe only when the owning store cannot contain two live instances of that bridge; otherwise scope the route primitive by instance. Keep URL writes in route and query-state APIs.
|
||||
- A route, framework, or permission bridge that must follow later owner changes should use `dangerouslyForceHydrate: true` at that controlled boundary so descendants observe one synchronous authoritative snapshot. This is the canonical exception to ordinary render-phase write guidance when the hydration tuples contain only those authoritative external inputs and the target store or scope belongs to the current instance. The resulting React warning alone is not an ownership problem, but it must not be used to dismiss cross-instance sharing.
|
||||
- A scoped workflow input is different: initialize it once, key the scope by semantic identity when switching entities should reset it, and do not force later parent refreshes into an in-progress session. Never extend the bridge exception to feature-owned primitives, drafts, query or mutation atoms, or edit-session snapshots.
|
||||
- Within one route-owned feature, choose one route-identity source. Do not hydrate route identity into atoms while also threading the same ID through multiple component layers.
|
||||
- A route parameter may cross the route-to-feature entry edge once. After a route bridge exists, queries, facts, commands, and descendant surfaces must read that bridge instead of accepting the same ID as props.
|
||||
- Put shareable filters, tabs, selected panels, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions.
|
||||
|
||||
## Jotai And Query
|
||||
## Build A Clean Jotai Graph
|
||||
|
||||
- A Jotai-backed feature may keep one feature-local state module ordered by dependency: types and constants, primitives, query atoms, query-data derivations, business facts, commands, mutations, submission orchestration, and provider exports.
|
||||
- Keep one feature-local state file or folder ordered by dependency: primitives and boundary inputs, query atoms, query-data selectors, business facts, commands, mutation atoms when justified, submission orchestration, then provider exports.
|
||||
- Keep graph flow one-way: primitive inputs -> queries -> named facts -> commands. Do not let components maintain a second writable entry for the same fact.
|
||||
- Create a derived atom only when its value is reused, consumed by another atom, or deserves a stable business name such as `rows`, `showEmpty`, or `canSubmit`. Leave one-off presentation assembly in the component.
|
||||
- Keep internal graph nodes unexported. Export only atoms that a component, provider boundary, or another state module must read or write.
|
||||
- Use `atomWithQuery` or `atomWithMutation` for async work driven by atom state. Do not hand-roll loading, error, or in-flight state for atom-orchestrated work.
|
||||
- Use field-specific derived atoms for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result.
|
||||
- Use `selectAtom` or another field-specific derived atom for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result. Read the whole result only when the consumer truly needs observer methods or a coordinated group of fields.
|
||||
- Name derived atoms as business facts and write atoms as user or workflow commands. Commands should express actions such as selecting a source or submitting a wizard, not merely rename setter callbacks.
|
||||
- Treat query keys, observer methods, cache invalidation, retries, and refresh composition as graph internals. Export a named command such as `refreshDocument`, not the query key plus a raw `refetch` callback.
|
||||
- A headless runtime controller may synchronize atoms with storage, timers, subscriptions, or other external systems. It should read and write focused graph nodes and render no UI; it must not return a large object for a parent to redistribute.
|
||||
|
||||
## Scope, Isolation, And Reset
|
||||
|
||||
- Leave query and mutation atoms unscoped so they retain the shared QueryClient cache. Scope resettable primitives and hydration tuples; scope a derived atom only when all dependencies should be private to the surface.
|
||||
- Use non-null lazy primitives for values always hydrated by a scope provider. Name derived atoms as business facts and write atoms as user or workflow commands.
|
||||
- `jotai-scope` recursively scopes the dependencies of an explicitly scoped derived or write atom. Review that transitive dependency closure, not only the provider's atom list: scoping a fact or command that reaches a query atom can also privatize route inputs, `queryClientAtom`, and other cache dependencies. Keep query-facing facts and commands outside that scope, or redesign the command to receive the event snapshot it needs instead of reading the query graph.
|
||||
- Do not add a derived fact or write command to the scoped list merely because it reads or writes scoped primitives. When consumed inside the scope, an unlisted fact or command can resolve those selected primitive dependencies from the current scope while retaining access to unscoped parent atoms; listing it instead recursively privatizes its dependency closure.
|
||||
- Prefer `ScopeProvider` when an instance must isolate selected workflow atoms while retaining access to atoms in the parent application store. A nested `Provider` or `createStore` boundary cuts interoperability with every parent atom, not only `queryClientAtom`; injecting the current QueryClient repairs cache access but does not restore other global state. Use a fresh store only when full-store isolation is intentional, then inject every required external dependency explicitly.
|
||||
- Use parent-store interoperation as the boundary test: if descendants must observe application auth, workspace, permissions, cache, or any other parent atom, a fresh `Provider` is not an instance-scope mechanism. Choose it only when the surface intentionally owns a complete store and must not observe later parent-atom updates.
|
||||
- For an intentionally unscoped route or parent-input bridge, call `useHydrateAtoms` in the boundary component that returns `ScopeProvider`; hooks read the nearest provider available where that component itself renders. When the bridge requires per-instance isolation, initialize its primitives through the `ScopeProvider` tuples instead. If later authoritative owner changes must follow, render a dedicated hydration bridge beneath that provider so `useHydrateAtoms` resolves the scoped primitives.
|
||||
- Use `atomWithLazy` or another non-null lazy primitive for required values injected by a scope provider; fail when the boundary forgot to provide them instead of inventing an empty ID.
|
||||
- Maintain the scoped primitive list explicitly. Use a semantic `key` when switching identity should create a fresh session; do not use forced hydration for an edit snapshot that must remain stable while an outer query refreshes.
|
||||
- Scope exists for per-instance isolation and natural reset, not as a general module boundary. A state file may be warranted even when its atoms remain unscoped.
|
||||
- Keep independent dialog lifecycles separate. A scoped open-state atom is acceptable only when composed sibling surfaces would otherwise pass confusing lifecycle props through unrelated owners.
|
||||
|
||||
## Graph Review Checklist
|
||||
|
||||
Before implementation and again after the final slice, draw the actual graph in dependency order and verify:
|
||||
|
||||
- every route, URL, parent, and persisted input has one bridge;
|
||||
- every query driven by graph input is represented in the graph and remains unscoped;
|
||||
- components read field selectors or named facts rather than complete query results;
|
||||
- write atoms and exported commands describe user or workflow intent;
|
||||
- scoped atoms are limited to primitives and snapshots with a documented reset boundary;
|
||||
- the scope still observes required parent/global atom updates, including the application's shared QueryClient;
|
||||
- two simultaneously mounted route or surface instances with different identities or URL state do not overwrite each other's bridged primitives or scoped workflow state, and a semantic identity change resets the intended state;
|
||||
- no equivalent value remains available through both atoms and descendant props;
|
||||
- every exported atom has a real component, boundary, or state-module consumer.
|
||||
|
||||
## Persistence
|
||||
|
||||
- Use feature-owned storage modules built on `createLocalStorageState`; callers should not scatter direct storage access or raw keys.
|
||||
|
||||
62
.github/scripts/knowledge-deploy-gates.cjs
vendored
Normal file
62
.github/scripts/knowledge-deploy-gates.cjs
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
// A workflow_run event is privileged: accept only trusted pushes and the exact tested revision.
|
||||
module.exports = async function knowledgeDeployGates({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
now = Date.now,
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
}) {
|
||||
const source = context.payload.workflow_run
|
||||
const repository = `${context.repo.owner}/${context.repo.repo}`
|
||||
const branch = 'deploy/konwledge'
|
||||
if (
|
||||
source?.event !== 'push' ||
|
||||
source.conclusion !== 'success' ||
|
||||
source.head_branch !== branch ||
|
||||
source.head_repository?.full_name !== repository ||
|
||||
!/^[0-9a-f]{40}$/.test(source.head_sha)
|
||||
) {
|
||||
throw new Error('Deployment requires a successful trusted branch push.')
|
||||
}
|
||||
const isCurrent = async () => {
|
||||
const { data } = await github.rest.repos.getBranch({ ...context.repo, branch })
|
||||
return data.commit.sha === source.head_sha
|
||||
}
|
||||
const skip = () => {
|
||||
core.info('Skipping a superseded deployment revision.')
|
||||
core.setOutput('current', 'false')
|
||||
}
|
||||
if (!(await isCurrent())) return skip()
|
||||
const deadline = now() + 30 * 60 * 1000
|
||||
while (now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'knowledge-fs-ci.yml',
|
||||
branch,
|
||||
event: 'push',
|
||||
head_sha: source.head_sha,
|
||||
per_page: 100,
|
||||
})
|
||||
const run = data.workflow_runs
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.head_sha === source.head_sha &&
|
||||
candidate.head_branch === branch &&
|
||||
candidate.event === 'push' &&
|
||||
candidate.head_repository?.full_name === repository,
|
||||
)
|
||||
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt)[0]
|
||||
if (run?.status === 'completed') {
|
||||
if (run.conclusion !== 'success')
|
||||
throw new Error(`KnowledgeFS CI did not succeed for ${source.head_sha}: ${run.conclusion}.`)
|
||||
if (!(await isCurrent())) return skip()
|
||||
core.info(`KnowledgeFS CI and image build succeeded for ${source.head_sha}.`)
|
||||
core.setOutput('current', 'true')
|
||||
return
|
||||
}
|
||||
if (!(await isCurrent())) return skip()
|
||||
core.info(`Waiting for KnowledgeFS CI at ${source.head_sha}.`)
|
||||
await sleep(15000)
|
||||
}
|
||||
throw new Error(`Timed out waiting for KnowledgeFS CI for ${source.head_sha}.`)
|
||||
}
|
||||
128
.github/scripts/knowledge-deploy-gates.test.cjs
vendored
Normal file
128
.github/scripts/knowledge-deploy-gates.test.cjs
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const gate = require('./knowledge-deploy-gates.cjs')
|
||||
const sha = 'a'.repeat(40)
|
||||
const otherSha = 'b'.repeat(40)
|
||||
function fixture() {
|
||||
const source = {
|
||||
event: 'push',
|
||||
conclusion: 'success',
|
||||
head_branch: 'deploy/konwledge',
|
||||
head_repository: { full_name: 'langgenius/dify' },
|
||||
head_sha: sha,
|
||||
}
|
||||
let time = 0
|
||||
let branchSha = sha
|
||||
let runs = [{ ...source, status: 'completed', run_number: 2, run_attempt: 1 }]
|
||||
const outputs = {}
|
||||
const requests = []
|
||||
return {
|
||||
source,
|
||||
outputs,
|
||||
requests,
|
||||
setRuns: (value) => {
|
||||
runs = value
|
||||
},
|
||||
setBranch: (value) => {
|
||||
branchSha = value
|
||||
},
|
||||
args: {
|
||||
context: { repo: { owner: 'langgenius', repo: 'dify' }, payload: { workflow_run: source } },
|
||||
github: {
|
||||
rest: {
|
||||
repos: { getBranch: async () => ({ data: { commit: { sha: branchSha } } }) },
|
||||
actions: {
|
||||
listWorkflowRuns: async (request) => {
|
||||
requests.push(request)
|
||||
return { data: { workflow_runs: runs } }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
core: {
|
||||
info: () => {},
|
||||
setOutput: (key, value) => {
|
||||
outputs[key] = value
|
||||
},
|
||||
},
|
||||
now: () => time,
|
||||
sleep: async (ms) => {
|
||||
time += ms
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
test('only a successful CI run from the same branch, repo, event, and SHA admits deployment', async () => {
|
||||
const f = fixture()
|
||||
await gate(f.args)
|
||||
assert.equal(f.outputs.current, 'true')
|
||||
assert.equal(f.requests[0].head_sha, sha)
|
||||
assert.equal(f.requests[0].workflow_id, 'knowledge-fs-ci.yml')
|
||||
})
|
||||
for (const [name, mutation] of [
|
||||
['pull request', { event: 'pull_request' }],
|
||||
['failed build', { conclusion: 'failure' }],
|
||||
['foreign branch', { head_branch: 'main' }],
|
||||
['foreign repo', { head_repository: { full_name: 'attacker/fork' } }],
|
||||
['invalid revision', { head_sha: 'main' }],
|
||||
])
|
||||
test(`rejects ${name} workflow events before any API query`, async () => {
|
||||
const f = fixture()
|
||||
Object.assign(f.source, mutation)
|
||||
await assert.rejects(gate(f.args), /trusted branch push/)
|
||||
assert.deepEqual(f.requests, [])
|
||||
})
|
||||
for (const conclusion of ['failure', 'cancelled', 'skipped', 'timed_out', 'neutral'])
|
||||
test(`CI ${conclusion} fails closed`, async () => {
|
||||
const f = fixture()
|
||||
f.setRuns([{ ...f.source, status: 'completed', conclusion }])
|
||||
await assert.rejects(gate(f.args), /did not succeed/)
|
||||
assert.notEqual(f.outputs.current, 'true')
|
||||
})
|
||||
for (const mutation of [
|
||||
{ head_sha: otherSha },
|
||||
{ head_branch: 'main' },
|
||||
{ event: 'pull_request' },
|
||||
{ head_repository: { full_name: 'attacker/fork' } },
|
||||
])
|
||||
test(`ignores unrelated successful CI run ${JSON.stringify(mutation)}`, async () => {
|
||||
const f = fixture()
|
||||
f.setRuns([{ ...f.source, ...mutation, status: 'completed' }])
|
||||
await assert.rejects(gate(f.args), /Timed out/)
|
||||
assert.notEqual(f.outputs.current, 'true')
|
||||
})
|
||||
test('latest failed attempt cannot be hidden by an earlier successful run', async () => {
|
||||
const f = fixture()
|
||||
f.setRuns([
|
||||
{ ...f.source, status: 'completed', run_number: 1, run_attempt: 1 },
|
||||
{ ...f.source, status: 'completed', run_number: 2, run_attempt: 1, conclusion: 'failure' },
|
||||
])
|
||||
await assert.rejects(gate(f.args), /did not succeed/)
|
||||
})
|
||||
test('stale revision skips without waiting or deploying', async () => {
|
||||
const f = fixture()
|
||||
f.setBranch(otherSha)
|
||||
await gate(f.args)
|
||||
assert.equal(f.outputs.current, 'false')
|
||||
assert.deepEqual(f.requests, [])
|
||||
})
|
||||
test('branch advancing during CI wait prevents deployment', async () => {
|
||||
const f = fixture()
|
||||
f.setRuns([{ ...f.source, status: 'in_progress' }])
|
||||
f.args.sleep = async () => {
|
||||
f.setBranch(otherSha)
|
||||
f.setRuns([{ ...f.source, status: 'completed' }])
|
||||
}
|
||||
await gate(f.args)
|
||||
assert.equal(f.outputs.current, 'false')
|
||||
})
|
||||
test('running CI is awaited before reporting success', async () => {
|
||||
const f = fixture()
|
||||
f.setRuns([])
|
||||
f.args.sleep = async () => {
|
||||
f.setRuns([{ ...f.source, status: 'completed' }])
|
||||
}
|
||||
await gate(f.args)
|
||||
assert.equal(f.outputs.current, 'true')
|
||||
assert.equal(f.requests.length, 2)
|
||||
})
|
||||
3
.github/workflows/cli-edge.yml
vendored
3
.github/workflows/cli-edge.yml
vendored
@@ -2,10 +2,11 @@ name: CLI Edge Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, deploy/konwledge]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
- '.github/workflows/cli-edge.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
91
.github/workflows/deploy-knowledge.yml
vendored
91
.github/workflows/deploy-knowledge.yml
vendored
@@ -7,67 +7,72 @@ permissions:
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- 'deploy/konwledge'
|
||||
types:
|
||||
- completed
|
||||
branches: ['deploy/konwledge']
|
||||
types: [completed]
|
||||
|
||||
# A second workflow used to deploy the same server after KnowledgeFS CI. This is the single
|
||||
# Dify deployment owner; the independent KnowledgeFS release retains its own deployment lock.
|
||||
concurrency:
|
||||
group: deploy-knowledge-dify-test
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
timeout-minutes: 340
|
||||
if: |
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge'
|
||||
github.event.workflow_run.head_branch == 'deploy/konwledge' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
steps:
|
||||
- name: Wait for KnowledgeFS CI
|
||||
- name: Checkout tested deployment gates
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Require same-revision KnowledgeFS CI and current branch head
|
||||
id: gates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
timeout-minutes: 35
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const workflowId = "knowledge-fs-ci.yml";
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
const pollIntervalMs = 15 * 1000;
|
||||
await require('./.github/scripts/knowledge-deploy-gates.cjs')({ github, context, core });
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
branch: headBranch,
|
||||
event: "push",
|
||||
head_sha: headSha,
|
||||
per_page: 10,
|
||||
});
|
||||
const run = data.workflow_runs[0];
|
||||
|
||||
if (!run) {
|
||||
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
|
||||
} else if (run.status !== "completed") {
|
||||
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
|
||||
} else if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
|
||||
);
|
||||
} else {
|
||||
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
|
||||
|
||||
- name: Deploy to server
|
||||
- name: Deploy tested Dify revision while preserving KnowledgeFS
|
||||
if: steps.gates.outputs.current == 'true'
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
EXPECTED_DIFY_REVISION: ${{ github.event.workflow_run.head_sha }}
|
||||
DEPLOY_DIR: /home/ubuntu/langgenius
|
||||
with:
|
||||
host: ${{ secrets.SSH_NEW_RAG_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
# Allow the shared deployment lock and Celery warm shutdown to finish.
|
||||
command_timeout: 300m
|
||||
envs: EXPECTED_DIFY_REVISION,DEPLOY_DIR
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
set -euo pipefail
|
||||
[[ "$EXPECTED_DIFY_REVISION" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo 'Expected a full tested Dify revision.' >&2; exit 1;
|
||||
}
|
||||
# Verify the installed bridge forwards revision arguments before invoking it. Never
|
||||
# overwrite an operator's custom entrypoint or fall back to an unverified script.
|
||||
python3 - "$DEPLOY_DIR" <<'PY'
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project = Path(sys.argv[1]).resolve()
|
||||
controller = project / 'knowledge-fs-independent-deploy.py'
|
||||
expected = ('#!/usr/bin/env bash\nset -euo pipefail\nexec python3 ' +
|
||||
shlex.quote(str(controller)) + ' --dify --project-dir ' +
|
||||
shlex.quote(str(project)) + ' "$@"\n')
|
||||
if not controller.is_file() or (project / 'deploy.sh').read_text() != expected:
|
||||
raise SystemExit('Expected the installed KnowledgeFS-preserving Dify release bridge.')
|
||||
PY
|
||||
"$DEPLOY_DIR/deploy.sh" --expected-revision "$EXPECTED_DIFY_REVISION"
|
||||
|
||||
532
.github/workflows/knowledge-fs-ci.yml
vendored
Normal file
532
.github/workflows/knowledge-fs-ci.yml
vendored
Normal file
@@ -0,0 +1,532 @@
|
||||
name: KnowledgeFS CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
merge_group:
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: ['main', 'deploy/konwledge']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: knowledge-fs-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check KnowledgeFS changes
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
outputs:
|
||||
knowledge-fs: ${{ steps.changes.outputs.knowledge-fs }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect KnowledgeFS changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
filters: |
|
||||
knowledge-fs:
|
||||
- 'api/knowledge-fs-contract/**'
|
||||
- 'api/tests/knowledge_fs_vector_integration/**'
|
||||
- 'api/services/tidb_binding_service.py'
|
||||
- 'api/tests/unit_tests/services/test_tidb_binding_service.py'
|
||||
- 'api/providers/vdb/vdb-tidb-on-qdrant/**'
|
||||
- 'api/Dockerfile'
|
||||
- 'api/dev/generate_knowledge_fs_contract.py'
|
||||
- 'api/dev/knowledge_fs_product_contract.py'
|
||||
- 'api/knowledge-fs-contract.lock.json'
|
||||
- 'api/knowledge-fs-product-operation-gaps.json'
|
||||
- 'api/knowledge-fs-product-operations.json'
|
||||
- 'api/**/knowledge_fs/**'
|
||||
- 'api/**/*knowledge_fs*'
|
||||
- 'api/**/*knowledge-fs*'
|
||||
- 'api/.env.example'
|
||||
- 'api/app_factory.py'
|
||||
- 'api/commands/__init__.py'
|
||||
- 'api/controllers/console/__init__.py'
|
||||
- 'api/controllers/console/workspace/rbac.py'
|
||||
- 'api/controllers/inner_api/__init__.py'
|
||||
- 'api/controllers/service_api/__init__.py'
|
||||
- 'api/core/agent/base_agent_runner.py'
|
||||
- 'api/core/app/apps/agent_app/runtime_request_builder.py'
|
||||
- 'api/core/rbac/entities.py'
|
||||
- 'api/core/tools/__base/tool_runtime.py'
|
||||
- 'api/core/tools/builtin_tool/_position.yaml'
|
||||
- 'api/core/workflow/node_runtime.py'
|
||||
- 'api/core/workflow/nodes/agent_v2/runtime_request_builder.py'
|
||||
- 'api/extensions/ext_celery.py'
|
||||
- 'api/extensions/ext_commands.py'
|
||||
- 'api/models/__init__.py'
|
||||
- 'api/models/enums.py'
|
||||
- 'api/models/model.py'
|
||||
- 'api/services/account_service.py'
|
||||
- 'api/services/agent_tool_inner_service.py'
|
||||
- 'api/services/enterprise/rbac_service.py'
|
||||
- 'api/services/entities/agent_tool_inner.py'
|
||||
- 'api/services/vector_space_admission_service.py'
|
||||
- 'api/services/knowledge_fs/**'
|
||||
- 'api/services/knowledge_fs_capability.py'
|
||||
- 'api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py'
|
||||
- 'api/tests/unit_tests/controllers/console/workspace/test_rbac.py'
|
||||
- 'api/tests/unit_tests/core/agent/test_base_agent_runner.py'
|
||||
- 'api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py'
|
||||
- 'api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py'
|
||||
- 'api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py'
|
||||
- 'api/tests/unit_tests/core/workflow/test_node_runtime.py'
|
||||
- 'api/tests/unit_tests/services/enterprise/test_rbac_service.py'
|
||||
- 'api/tests/unit_tests/services/test_account_service.py'
|
||||
- 'api/tests/unit_tests/services/test_agent_tool_inner_service.py'
|
||||
- 'api/tests/unit_tests/services/test_vector_space_admission_service.py'
|
||||
- 'api/tests/unit_tests/services/test_knowledge_fs_capability.py'
|
||||
- 'api/tests/unit_tests/services/test_knowledge_fs_product_operations.py'
|
||||
- 'api/pyproject.toml'
|
||||
- 'api/uv.lock'
|
||||
- 'dify-agent/src/dify_agent/layers/dify_core_tools/client.py'
|
||||
- 'dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_client.py'
|
||||
- 'packages/contracts/generated/api/console/**'
|
||||
- 'packages/contracts/generated/api/service/**'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/README.md'
|
||||
- 'docker/dify-env-sync.py'
|
||||
- 'docker/dify-env-sync.sh'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/docker-compose.yaml'
|
||||
- 'docker/envs/core-services/api.env.example'
|
||||
- 'docker/envs/core-services/knowledge-fs.env.example'
|
||||
- 'docker/envs/core-services/knowledge-fs-unstructured-service.defaults'
|
||||
- 'docker/envs/core-services/knowledge-fs-unstructured.env.example'
|
||||
- 'docker/knowledge-fs-unstructured-sandbox.compose.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
- 'docs/design/knowledge-fs*'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/workflows/deploy-knowledge.yml'
|
||||
- '.github/workflows/deploy-dev.yml'
|
||||
- '.github/scripts/knowledge-deploy-*'
|
||||
- '.github/workflows/knowledge-fs-ci.yml'
|
||||
|
||||
quality:
|
||||
name: Run KnowledgeFS quality and contract gates
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.knowledge-fs == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: .
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node for deployment gate regression tests
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Test deployment revision gates
|
||||
run: node --test .github/scripts/knowledge-deploy-gates.test.cjs
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: |
|
||||
api/uv.lock
|
||||
dify-agent/uv.lock
|
||||
|
||||
- name: Verify Dify dependency lock
|
||||
working-directory: .
|
||||
run: uv lock --project api --check
|
||||
|
||||
- name: Install Dify contract dependencies
|
||||
working-directory: .
|
||||
run: uv sync --project api --locked --dev --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant
|
||||
|
||||
# The standalone KnowledgeFS repository owns TypeScript, parser, image and deployment gates.
|
||||
# This repository verifies its immutable published contract and all Dify integration code.
|
||||
- name: Verify pinned KnowledgeFS contract
|
||||
run: uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant python api/dev/generate_knowledge_fs_contract.py --check
|
||||
|
||||
- name: Collect Dify KnowledgeFS gate targets
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
target_dir="${RUNNER_TEMP:?}/knowledge-fs-ci-targets"
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
production_targets=()
|
||||
add_production_target() {
|
||||
local path="$1"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
echo "required Dify KnowledgeFS production target is missing: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
production_targets+=("$path")
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
if [[ "$path" == *knowledge_fs* ]]; then
|
||||
add_production_target "$path"
|
||||
fi
|
||||
done < <(
|
||||
find api \
|
||||
\( -path 'api/.venv' -o -path 'api/tests' -o -path 'api/storage' \) -prune \
|
||||
-o -type f -name '*.py' -print0
|
||||
)
|
||||
|
||||
production_touchpoints=(
|
||||
api/app_factory.py
|
||||
api/commands/__init__.py
|
||||
api/controllers/console/__init__.py
|
||||
api/controllers/console/datasets/datasets.py
|
||||
api/controllers/console/workspace/rbac.py
|
||||
api/controllers/inner_api/__init__.py
|
||||
api/controllers/service_api/__init__.py
|
||||
api/core/agent/base_agent_runner.py
|
||||
api/core/app/apps/agent_app/runtime_request_builder.py
|
||||
api/core/rbac/entities.py
|
||||
api/core/tools/__base/tool_runtime.py
|
||||
api/core/workflow/node_runtime.py
|
||||
api/core/workflow/nodes/agent_v2/runtime_request_builder.py
|
||||
api/extensions/ext_celery.py
|
||||
api/extensions/ext_commands.py
|
||||
api/models/__init__.py
|
||||
api/models/enums.py
|
||||
api/models/model.py
|
||||
api/services/account_service.py
|
||||
api/services/agent_tool_inner_service.py
|
||||
api/services/enterprise/rbac_service.py
|
||||
api/services/entities/agent_tool_inner.py
|
||||
api/services/vector_space_admission_service.py
|
||||
api/services/tidb_binding_service.py
|
||||
api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py
|
||||
api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py
|
||||
)
|
||||
for path in "${production_touchpoints[@]}"; do
|
||||
add_production_target "$path"
|
||||
done
|
||||
printf '%s\0' "${production_touchpoints[@]}" > "$target_dir/glue-files"
|
||||
|
||||
if ((${#production_targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS production target set is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\0' "${production_targets[@]}" > "$target_dir/production-files"
|
||||
|
||||
test_targets=()
|
||||
add_test_target() {
|
||||
local path="$1"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
echo "required Dify KnowledgeFS unit test is missing: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
test_targets+=("$path")
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
if [[ "$path" == *knowledge_fs* ]]; then
|
||||
add_test_target "$path"
|
||||
fi
|
||||
done < <(find api/tests/unit_tests -type f -name '*.py' -print0)
|
||||
|
||||
test_touchpoints=(
|
||||
api/tests/unit_tests/controllers/console/datasets/test_datasets.py
|
||||
api/tests/unit_tests/controllers/console/test_wraps.py
|
||||
api/tests/unit_tests/controllers/console/workspace/test_rbac.py
|
||||
api/tests/unit_tests/core/agent/test_base_agent_runner.py
|
||||
api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py
|
||||
api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
|
||||
api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py
|
||||
api/tests/unit_tests/core/workflow/test_node_runtime.py
|
||||
api/tests/unit_tests/services/enterprise/test_rbac_service.py
|
||||
api/tests/unit_tests/services/test_account_service.py
|
||||
api/tests/unit_tests/services/test_agent_tool_inner_service.py
|
||||
api/tests/unit_tests/services/test_vector_space_admission_service.py
|
||||
api/tests/unit_tests/services/test_tidb_binding_service.py
|
||||
)
|
||||
for path in "${test_touchpoints[@]}"; do
|
||||
add_test_target "$path"
|
||||
done
|
||||
|
||||
required_test_scopes=(
|
||||
/commands/
|
||||
/configs/
|
||||
/controllers/
|
||||
/core/agent/
|
||||
/core/app/apps/agent_app/
|
||||
/core/tools/builtin_tool/providers/knowledge_fs/
|
||||
/core/workflow/
|
||||
/dev/
|
||||
/extensions/
|
||||
/migrations/
|
||||
/models/
|
||||
/repositories/
|
||||
/services/
|
||||
/tasks/
|
||||
)
|
||||
for required_scope in "${required_test_scopes[@]}"; do
|
||||
scope_found=false
|
||||
for path in "${test_targets[@]}"; do
|
||||
if [[ "$path" == *"$required_scope"* ]]; then
|
||||
scope_found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$scope_found" != true ]]; then
|
||||
echo "required Dify KnowledgeFS test scope is empty: $required_scope" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#test_targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS unit test target set is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\0' "${test_targets[@]}" > "$target_dir/unit-test-files"
|
||||
|
||||
- name: Lint Dify KnowledgeFS integration
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
targets=()
|
||||
while IFS= read -r -d '' path; do
|
||||
targets+=("$path")
|
||||
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/production-files"
|
||||
if ((${#targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS production target manifest is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev ruff format --check "${targets[@]}"
|
||||
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev ruff check "${targets[@]}"
|
||||
|
||||
- name: Type-check Dify KnowledgeFS integration
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
targets=()
|
||||
while IFS= read -r -d '' path; do
|
||||
targets+=("$path")
|
||||
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/production-files"
|
||||
if ((${#targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS production target manifest is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
UV_NO_SYNC=1 PYREFLY_OUTPUT_FORMAT=github ./dev/pyrefly-check-local "${targets[@]}"
|
||||
|
||||
mypy_targets=()
|
||||
for path in "${targets[@]}"; do
|
||||
if [[ "$path" != api/migrations/* ]]; then
|
||||
mypy_targets+=("${path#api/}")
|
||||
fi
|
||||
done
|
||||
if ((${#mypy_targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS Mypy target set is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --directory api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev mypy \
|
||||
--explicit-package-bases \
|
||||
--exclude-gitignore \
|
||||
--exclude '(^|/)conftest\.py$' \
|
||||
--exclude 'tests/' \
|
||||
--exclude 'migrations/' \
|
||||
--check-untyped-defs \
|
||||
--disable-error-code=import-untyped \
|
||||
"${mypy_targets[@]}"
|
||||
|
||||
- name: Test Dify KnowledgeFS unit surface
|
||||
working-directory: .
|
||||
env:
|
||||
COVERAGE_FILE: ${{ runner.temp }}/dify-knowledge-fs.coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
targets=()
|
||||
while IFS= read -r -d '' path; do
|
||||
targets+=("$path")
|
||||
done < "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/unit-test-files"
|
||||
if ((${#targets[@]} == 0)); then
|
||||
echo "Dify KnowledgeFS unit test target manifest is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev coverage run --branch --source=api -m pytest "${targets[@]}" --no-cov -q
|
||||
|
||||
- name: Enforce Dify KnowledgeFS focused coverage
|
||||
working-directory: .
|
||||
env:
|
||||
COVERAGE_FILE: ${{ runner.temp }}/dify-knowledge-fs.coverage
|
||||
KNOWLEDGE_FS_COVERAGE_BASE: >-
|
||||
${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report="${RUNNER_TEMP:?}/dify-knowledge-fs-coverage.json"
|
||||
coverage_base="$KNOWLEDGE_FS_COVERAGE_BASE"
|
||||
if [[ "$GITHUB_EVENT_NAME" == 'push' && "$GITHUB_REF" == 'refs/heads/deploy/konwledge' ]]; then
|
||||
latest_merge="$(git rev-list --first-parent --merges --max-count=1 "${coverage_base}..HEAD")"
|
||||
if [[ -n "$latest_merge" ]]; then
|
||||
# Mainline syncs are already gated on main; cover deployment-branch
|
||||
# changes made after the newest sync instead of the merged diff.
|
||||
coverage_base="$latest_merge"
|
||||
fi
|
||||
fi
|
||||
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev coverage json --show-contexts -o "$report"
|
||||
uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant --dev python api/dev/check_knowledge_fs_coverage.py \
|
||||
--coverage-json "$report" \
|
||||
--glue-manifest "${RUNNER_TEMP:?}/knowledge-fs-ci-targets/glue-files" \
|
||||
--base "$coverage_base" \
|
||||
--minimum 90 \
|
||||
--glue-minimum 90
|
||||
|
||||
- name: Verify Dify KnowledgeFS contract
|
||||
working-directory: .
|
||||
run: uv run --project api --group vdb-qdrant --group vdb-elasticsearch --group vdb-weaviate --group vdb-tidb-on-qdrant python api/dev/generate_knowledge_fs_contract.py --check
|
||||
|
||||
- name: Verify Dify Agent dependency lock
|
||||
working-directory: .
|
||||
run: uv lock --project dify-agent --check
|
||||
|
||||
- name: Install Dify Agent gate dependencies
|
||||
working-directory: .
|
||||
run: uv sync --project dify-agent --locked --dev
|
||||
|
||||
- name: Lint Dify Agent KnowledgeFS integration
|
||||
working-directory: ./dify-agent
|
||||
run: |
|
||||
uv run --project . --dev ruff format --check \
|
||||
src/dify_agent/layers/dify_core_tools/client.py \
|
||||
tests/local/dify_agent/layers/dify_core_tools/test_client.py
|
||||
uv run --project . --dev ruff check \
|
||||
src/dify_agent/layers/dify_core_tools/client.py \
|
||||
tests/local/dify_agent/layers/dify_core_tools/test_client.py
|
||||
|
||||
- name: Type-check Dify Agent KnowledgeFS integration
|
||||
working-directory: ./dify-agent
|
||||
run: >-
|
||||
uv run --project . --dev pyrefly check --summary=none
|
||||
src/dify_agent/layers/dify_core_tools/client.py
|
||||
tests/local/dify_agent/layers/dify_core_tools/test_client.py
|
||||
|
||||
- name: Test Dify Agent KnowledgeFS integration
|
||||
working-directory: ./dify-agent
|
||||
run: >-
|
||||
uv run --project . --dev python -m pytest
|
||||
tests/local/dify_agent/layers/dify_core_tools/test_client.py
|
||||
-q
|
||||
|
||||
skip:
|
||||
name: Skip KnowledgeFS quality and contract gates
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.knowledge-fs != 'true' && github.event_name != 'workflow_dispatch'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
steps:
|
||||
- name: Report skipped KnowledgeFS checks
|
||||
run: echo "No KnowledgeFS-related changes detected; skipping KnowledgeFS checks."
|
||||
|
||||
final:
|
||||
name: KnowledgeFS CI
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- check-changes
|
||||
- quality
|
||||
- vector-backends
|
||||
- skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
steps:
|
||||
- name: Finalize KnowledgeFS CI status
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
KNOWLEDGE_FS_CHANGED: ${{ needs.check-changes.outputs.knowledge-fs }}
|
||||
QUALITY_RESULT: ${{ needs.quality.result }}
|
||||
VECTOR_RESULT: ${{ needs.vector-backends.result }}
|
||||
SKIP_RESULT: ${{ needs.skip.result }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == 'workflow_dispatch' || "$KNOWLEDGE_FS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$QUALITY_RESULT" == 'success' && "$VECTOR_RESULT" == 'success' ]]; then
|
||||
echo "Dify KnowledgeFS contract and integration checks ran successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify KnowledgeFS integration checks failed: quality=$QUALITY_RESULT vectors=$VECTOR_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
echo "KnowledgeFS checks were skipped because no related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "KnowledgeFS change detection or skip reporting failed with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
vector-backends:
|
||||
name: Test native KnowledgeFS vector backends
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.knowledge-fs == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
services:
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.3
|
||||
env:
|
||||
discovery.type: single-node
|
||||
xpack.security.enabled: 'false'
|
||||
ES_JAVA_OPTS: '-Xms512m -Xmx512m'
|
||||
ports:
|
||||
- 9200:9200
|
||||
options: >-
|
||||
--health-cmd "curl -fsS http://localhost:9200/_cluster/health"
|
||||
--health-interval 10s --health-timeout 5s --health-retries 30
|
||||
weaviate:
|
||||
image: cr.weaviate.io/semitechnologies/weaviate:1.39.2
|
||||
env:
|
||||
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
|
||||
PERSISTENCE_DATA_PATH: /var/lib/weaviate
|
||||
DEFAULT_VECTORIZER_MODULE: none
|
||||
CLUSTER_HOSTNAME: node1
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 50051:50051
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
- name: Install native vector SDKs
|
||||
run: uv sync --project api --locked --dev --group vdb-elasticsearch --group vdb-weaviate
|
||||
- name: Wait for Weaviate readiness
|
||||
run: |
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:8080/v1/.well-known/ready; then exit 0; fi
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
- name: Exercise real write, retry, filtered search, isolation and deletion
|
||||
env:
|
||||
KNOWLEDGE_FS_VECTOR_INTEGRATION: '1'
|
||||
DEPLOYMENT_EDITION: COMMUNITY
|
||||
ELASTICSEARCH_HOST: localhost
|
||||
ELASTICSEARCH_PORT: '9200'
|
||||
WEAVIATE_ENDPOINT: http://localhost:8080
|
||||
WEAVIATE_GRPC_ENDPOINT: grpc://localhost:50051
|
||||
run: >-
|
||||
uv run --project api --no-sync pytest
|
||||
api/tests/knowledge_fs_vector_integration --no-cov -q
|
||||
@@ -724,11 +724,27 @@ AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_ENABLED=false
|
||||
# Explicitly bypass the Community edition gate for development deployments.
|
||||
KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED=false
|
||||
# Production deployments require HTTPS; plain HTTP is limited to non-production or loopback.
|
||||
KNOWLEDGE_FS_BASE_URL=
|
||||
# Shared with KnowledgeFS; use at least 32 random characters.
|
||||
KNOWLEDGE_FS_JWT_SECRET=
|
||||
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300
|
||||
KNOWLEDGE_FS_DIRECT_ORIGIN=
|
||||
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED=false
|
||||
KNOWLEDGE_FS_INTEGRATED_PROVISION_READY=false
|
||||
KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY=false
|
||||
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS=15
|
||||
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS=60
|
||||
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE=25
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=false
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID=
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS=
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_ISSUER=dify-control-plane
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_AUDIENCE=knowledge-fs
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_MAX_TTL_SECONDS=60
|
||||
KNOWLEDGE_FS_TIMEOUT_SECONDS=10
|
||||
KNOWLEDGE_FS_JWKS_CACHE_MAX_AGE_SECONDS=300
|
||||
KNOWLEDGE_FS_PRODUCT_MAX_RESPONSE_BYTES=4194304
|
||||
|
||||
# Marketplace configuration
|
||||
MARKETPLACE_ENABLED=true
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# KnowledgeFS is released independently. Its release controller atomically mounts the tested
|
||||
# engine bundles and native dependencies at /app/knowledge-fs in the dedicated Celery workers.
|
||||
# Ordinary Dify API images do not rebuild or download a second copy of the private engine.
|
||||
# base image
|
||||
FROM python:3.12-slim-bookworm AS base
|
||||
|
||||
@@ -89,6 +92,8 @@ RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \
|
||||
media-types \
|
||||
# install libmagic to support the use of python-magic guess MIMETYPE
|
||||
libmagic1 \
|
||||
# Bounded PDF metadata/rasterization invoked by the local KnowledgeFS worker engine.
|
||||
poppler-utils \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -7,10 +7,27 @@
|
||||
!dify-agent/README.md
|
||||
!dify-agent/src/
|
||||
!dify-agent/src/**
|
||||
!knowledge-fs/
|
||||
!knowledge-fs/package.json
|
||||
!knowledge-fs/pnpm-lock.yaml
|
||||
!knowledge-fs/pnpm-workspace.yaml
|
||||
!knowledge-fs/turbo.json
|
||||
!knowledge-fs/tsconfig.base.json
|
||||
!knowledge-fs/apps/
|
||||
!knowledge-fs/apps/api/
|
||||
!knowledge-fs/apps/api/**
|
||||
!knowledge-fs/packages/
|
||||
!knowledge-fs/packages/**
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.turbo/
|
||||
|
||||
# Environment configuration and example
|
||||
.env
|
||||
*.env.*
|
||||
**/.env
|
||||
**/*.env
|
||||
**/*.env.*
|
||||
|
||||
# Python related files
|
||||
**/__pycache__
|
||||
|
||||
@@ -50,7 +50,9 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
./dev/start-worker
|
||||
```
|
||||
|
||||
1. Optional: start Celery Beat (scheduled tasks).
|
||||
1. Start Celery Beat when scheduled tasks are needed. This is required when
|
||||
`KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED=true` so provisioning and deletion
|
||||
outbox commands are dispatched to the lifecycle worker.
|
||||
|
||||
```bash
|
||||
./dev/start-beat
|
||||
|
||||
@@ -190,6 +190,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_hosting_provider,
|
||||
ext_import_modules,
|
||||
ext_key_provider,
|
||||
ext_knowledge_fs_observability,
|
||||
ext_logging,
|
||||
ext_login,
|
||||
ext_logstore,
|
||||
@@ -241,6 +242,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_enterprise_telemetry,
|
||||
ext_request_logging,
|
||||
ext_session_factory,
|
||||
ext_knowledge_fs_observability,
|
||||
ext_application_services,
|
||||
ext_oauth_bearer,
|
||||
]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import psycogreen.gevent as pscycogreen_gevent
|
||||
from grpc.experimental import gevent as grpc_gevent
|
||||
from gevent.monkey import is_module_patched
|
||||
|
||||
# grpc gevent
|
||||
grpc_gevent.init_gevent()
|
||||
print("gRPC patched with gevent.", flush=True) # noqa: T201
|
||||
pscycogreen_gevent.patch_psycopg()
|
||||
print("psycopg2 patched with gevent.", flush=True) # noqa: T201
|
||||
# Celery patches the stdlib before importing this entrypoint for -P gevent. Prefork workers
|
||||
# (including the KnowledgeFS local engine) must retain native subprocess, DB and gRPC behavior.
|
||||
if is_module_patched("socket"):
|
||||
import psycogreen.gevent as pscycogreen_gevent
|
||||
from grpc.experimental import gevent as grpc_gevent
|
||||
|
||||
grpc_gevent.init_gevent()
|
||||
print("gRPC patched with gevent.", flush=True) # noqa: T201
|
||||
pscycogreen_gevent.patch_psycopg()
|
||||
print("psycopg2 patched with gevent.", flush=True) # noqa: T201
|
||||
|
||||
|
||||
from app import app, celery
|
||||
|
||||
@@ -33,6 +33,7 @@ from dify_agent.layers.execution_context import (
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge_fs import DIFY_KNOWLEDGE_FS_LAYER_TYPE_ID, DifyKnowledgeFsLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
@@ -79,6 +80,17 @@ def _config_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _knowledge_fs_spec(run_input: AgentBackendWorkflowNodeRunInput | AgentBackendAgentAppRunInput) -> RunLayerSpec:
|
||||
if not run_input.include_shell or run_input.knowledge is not None:
|
||||
raise ValueError("KnowledgeFS requires shell and cannot be combined with legacy knowledge retrieval")
|
||||
return RunLayerSpec(
|
||||
name="knowledge_fs",
|
||||
type=DIFY_KNOWLEDGE_FS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, "shell": DIFY_SHELL_LAYER_ID},
|
||||
config=run_input.knowledge_fs,
|
||||
)
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
@@ -212,6 +224,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
knowledge_fs: DifyKnowledgeFsLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
@@ -259,6 +272,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
knowledge_fs: DifyKnowledgeFsLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
@@ -412,6 +426,8 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge_fs is not None:
|
||||
layers.append(_knowledge_fs_spec(run_input))
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@@ -590,6 +606,8 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge_fs is not None:
|
||||
layers.append(_knowledge_fs_spec(run_input))
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@@ -10,6 +10,7 @@ from .data_migration import (
|
||||
import_migration_data,
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .knowledge_fs import knowledge_fs_control_space
|
||||
from .plugin import (
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
@@ -81,6 +82,7 @@ __all__ = [
|
||||
"import_migration_data",
|
||||
"install_plugins",
|
||||
"install_rag_pipeline_plugins",
|
||||
"knowledge_fs_control_space",
|
||||
"legacy_model_types",
|
||||
"migrate_agent_permissions_to_rbac",
|
||||
"migrate_annotation_vector_database",
|
||||
|
||||
621
api/commands/knowledge_fs.py
Normal file
621
api/commands/knowledge_fs.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""Operator commands for the independent KnowledgeFS control-plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from services.knowledge_fs.cleanup import (
|
||||
CleanupApprovalInput,
|
||||
CleanupCompletionEvidenceInput,
|
||||
CleanupReadinessEvidenceInput,
|
||||
CleanupStartInput,
|
||||
KnowledgeFSCleanupError,
|
||||
KnowledgeFSCleanupService,
|
||||
)
|
||||
from services.knowledge_fs.control_space_commands import KnowledgeFSControlSpaceCommandService
|
||||
from services.knowledge_fs.control_space_lifecycle import KnowledgeFSControlSpaceLifecycleError
|
||||
from services.knowledge_fs.control_space_management import (
|
||||
KnowledgeFSControlSpaceManagementService,
|
||||
KnowledgeFSControlSpaceRegistration,
|
||||
)
|
||||
from services.knowledge_fs.cutover import (
|
||||
CutoverSmokeResultsInput,
|
||||
FinalDeltaInput,
|
||||
KnowledgeFSCutoverError,
|
||||
KnowledgeFSWorkspaceCutoverService,
|
||||
LegacyDependencyInput,
|
||||
QuarantineResolutionInput,
|
||||
ShadowAuthorizationObservationInput,
|
||||
ShadowCompletionInput,
|
||||
WorkspaceInventoryInput,
|
||||
)
|
||||
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
|
||||
from services.knowledge_fs.orphan_reconciler import KnowledgeFSOrphanReconciler
|
||||
from services.knowledge_fs.remote_registry import get_knowledge_fs_lifecycle_remote
|
||||
|
||||
|
||||
@click.group("knowledge-fs-control-space")
|
||||
def knowledge_fs_control_space() -> None:
|
||||
"""Inspect and repair Dify-owned KnowledgeFS control-space state."""
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("dry-run")
|
||||
@click.option("--tenant-id", default=None)
|
||||
def dry_run(tenant_id: str | None) -> None:
|
||||
report = _management_service().dry_run(tenant_id=tenant_id)
|
||||
click.echo(json.dumps(report._asdict(), sort_keys=True))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("inventory")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Create ledgers; omitted means read-only inventory.")
|
||||
def inventory(input_path: Path, apply: bool) -> None:
|
||||
"""Validate strict Workspace inventory JSONL and optionally create ledgers."""
|
||||
|
||||
service = _cutover_service()
|
||||
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
|
||||
report = _operator_call(partial(service.inventory, payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("register")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--owner-account-id", required=True)
|
||||
@click.option("--provisioning-key", required=True)
|
||||
@click.option("--knowledge-space-id", required=True)
|
||||
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
|
||||
def register(
|
||||
tenant_id: str,
|
||||
owner_account_id: str,
|
||||
provisioning_key: str,
|
||||
knowledge_space_id: str,
|
||||
knowledge_space_revision: int,
|
||||
) -> None:
|
||||
control_space, replayed = _management_service().register(
|
||||
KnowledgeFSControlSpaceRegistration(
|
||||
tenant_id,
|
||||
owner_account_id,
|
||||
provisioning_key,
|
||||
knowledge_space_id,
|
||||
knowledge_space_revision,
|
||||
)
|
||||
)
|
||||
click.echo(json.dumps({"control_space_id": control_space.id, "replayed": replayed}, sort_keys=True))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("backfill")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist registrations; omitted means dry-run.")
|
||||
def backfill(input_path: Path, apply: bool) -> None:
|
||||
"""Backfill strict Workspace inventory JSONL; dry-run unless --apply is explicit."""
|
||||
|
||||
service = _cutover_service()
|
||||
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
|
||||
report = _operator_call(partial(service.backfill, payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("quarantine-resolve")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist resolutions; omitted means dry-run.")
|
||||
def quarantine_resolve(input_path: Path, apply: bool) -> None:
|
||||
"""Resolve strict tenant-scoped quarantine JSONL with immutable operator evidence."""
|
||||
|
||||
service = _cutover_service()
|
||||
for payload in _read_jsonl(input_path, QuarantineResolutionInput):
|
||||
report = _operator_call(partial(service.resolve_quarantine, payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("shadow-start")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--at", "started_at", default=None, help="Optional explicit timezone-aware shadow start.")
|
||||
def shadow_start(tenant_id: str, expected_cas_version: int, started_at: str | None) -> None:
|
||||
service = _cutover_service()
|
||||
_operator_call(
|
||||
lambda: service.begin_shadow(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
started_at=_parse_timestamp(started_at) if started_at is not None else None,
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("shadow-report")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist observations; omitted means dry-run.")
|
||||
def shadow_report(input_path: Path, apply: bool) -> None:
|
||||
observations = _read_jsonl(input_path, ShadowAuthorizationObservationInput)
|
||||
report = _operator_call(lambda: _cutover_service().record_shadow_report(observations, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("shadow-complete")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist completion; omitted means dry-run.")
|
||||
def shadow_complete(input_path: Path, apply: bool) -> None:
|
||||
payload = _read_one_jsonl(input_path, ShadowCompletionInput)
|
||||
report = _operator_call(lambda: _cutover_service().complete_shadow(payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("issue-approve")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--issue-key", required=True)
|
||||
@click.option("--account-id", required=True)
|
||||
@click.option("--at", "approved_at", required=True)
|
||||
def issue_approve(tenant_id: str, issue_key: str, account_id: str, approved_at: str) -> None:
|
||||
_operator_call(
|
||||
lambda: _cutover_service().approve_issue_fail_closed(
|
||||
tenant_id=tenant_id,
|
||||
issue_key=issue_key,
|
||||
account_id=account_id,
|
||||
approved_at=_parse_timestamp(approved_at),
|
||||
)
|
||||
)
|
||||
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("issue-resolve")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--issue-key", required=True)
|
||||
@click.option("--account-id", required=True)
|
||||
@click.option("--at", "resolved_at", required=True)
|
||||
def issue_resolve(tenant_id: str, issue_key: str, account_id: str, resolved_at: str) -> None:
|
||||
_operator_call(
|
||||
lambda: _cutover_service().resolve_issue(
|
||||
tenant_id=tenant_id,
|
||||
issue_key=issue_key,
|
||||
account_id=account_id,
|
||||
resolved_at=_parse_timestamp(resolved_at),
|
||||
)
|
||||
)
|
||||
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("shadow-approve")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--diff-key", required=True)
|
||||
@click.option("--account-id", required=True)
|
||||
@click.option("--at", "approved_at", required=True)
|
||||
def shadow_approve(tenant_id: str, diff_key: str, account_id: str, approved_at: str) -> None:
|
||||
_operator_call(
|
||||
lambda: _cutover_service().approve_shadow_diff(
|
||||
tenant_id=tenant_id,
|
||||
diff_key=diff_key,
|
||||
account_id=account_id,
|
||||
approved_at=_parse_timestamp(approved_at),
|
||||
)
|
||||
)
|
||||
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("shadow-resolve")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--diff-key", required=True)
|
||||
@click.option("--account-id", required=True)
|
||||
@click.option("--at", "resolved_at", required=True)
|
||||
def shadow_resolve(tenant_id: str, diff_key: str, account_id: str, resolved_at: str) -> None:
|
||||
_operator_call(
|
||||
lambda: _cutover_service().resolve_shadow_diff(
|
||||
tenant_id=tenant_id,
|
||||
diff_key=diff_key,
|
||||
account_id=account_id,
|
||||
resolved_at=_parse_timestamp(resolved_at),
|
||||
)
|
||||
)
|
||||
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("legacy-dashboard")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--checked-at", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), default=None)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist gate evidence; omitted means read-only report.")
|
||||
def legacy_dashboard(
|
||||
tenant_id: str,
|
||||
input_path: Path,
|
||||
checked_at: str,
|
||||
expected_cas_version: int | None,
|
||||
apply: bool,
|
||||
) -> None:
|
||||
dependencies = _read_jsonl(input_path, LegacyDependencyInput, allow_empty=True)
|
||||
report = _operator_call(
|
||||
lambda: _cutover_service().legacy_dependency_dashboard(
|
||||
tenant_id=tenant_id,
|
||||
dependencies=dependencies,
|
||||
expected_cas_version=expected_cas_version,
|
||||
checked_at=_parse_timestamp(checked_at),
|
||||
apply=apply,
|
||||
)
|
||||
)
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("legacy-check")
|
||||
@click.option("--tenant-id", required=True)
|
||||
def legacy_check(tenant_id: str) -> None:
|
||||
status_report = _operator_call(lambda: _cutover_service().status(tenant_id=tenant_id))
|
||||
passed = (
|
||||
bool(status_report["legacy_dependency_ready"])
|
||||
and status_report["open_issues"] == 0
|
||||
and status_report["unresolved_cutover_quarantine"] == 0
|
||||
)
|
||||
_echo_json({"tenant_id": tenant_id, "passed": passed, "status": status_report})
|
||||
if not passed:
|
||||
raise click.exceptions.Exit(1)
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("freeze")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--at", "freeze_at", required=True)
|
||||
def freeze(tenant_id: str, expected_cas_version: int, freeze_at: str) -> None:
|
||||
service = _cutover_service()
|
||||
_operator_call(
|
||||
lambda: service.freeze(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
freeze_at=_parse_timestamp(freeze_at),
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("final-delta")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
def final_delta(input_path: Path) -> None:
|
||||
payload = _read_one_jsonl(input_path, FinalDeltaInput)
|
||||
service = _cutover_service()
|
||||
_operator_call(lambda: service.apply_final_delta(payload))
|
||||
_echo_json(service.status(tenant_id=str(payload.tenant_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cutover")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--at", "cutover_at", required=True)
|
||||
@click.option("--rollback-cutoff-at", required=True)
|
||||
def cutover(tenant_id: str, expected_cas_version: int, cutover_at: str, rollback_cutoff_at: str) -> None:
|
||||
service = _cutover_service()
|
||||
_operator_call(
|
||||
lambda: service.cutover(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
cutover_at=_parse_timestamp(cutover_at),
|
||||
rollback_cutoff_at=_parse_timestamp(rollback_cutoff_at),
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("smoke")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
def smoke(tenant_id: str, expected_cas_version: int, input_path: Path) -> None:
|
||||
results = _read_one_jsonl(input_path, CutoverSmokeResultsInput)
|
||||
service = _cutover_service()
|
||||
_operator_call(
|
||||
lambda: service.record_smoke_results(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
results=results,
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("observe")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--started-at", default=None)
|
||||
@click.option("--window-ends-at", default=None)
|
||||
@click.option("--maximum-task-expires-at", default=None)
|
||||
@click.option("--observed-at", default=None)
|
||||
def observe(
|
||||
tenant_id: str,
|
||||
expected_cas_version: int,
|
||||
started_at: str | None,
|
||||
window_ends_at: str | None,
|
||||
maximum_task_expires_at: str | None,
|
||||
observed_at: str | None,
|
||||
) -> None:
|
||||
service = _cutover_service()
|
||||
if observed_at is not None:
|
||||
if any(value is not None for value in (started_at, window_ends_at, maximum_task_expires_at)):
|
||||
raise click.UsageError("--observed-at cannot be combined with observation start options")
|
||||
_operator_call(
|
||||
lambda: service.complete_observation(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
observed_at=_parse_timestamp(observed_at),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if started_at is None or window_ends_at is None or maximum_task_expires_at is None:
|
||||
raise click.UsageError(
|
||||
"observation start requires --started-at, --window-ends-at, and --maximum-task-expires-at"
|
||||
)
|
||||
_operator_call(
|
||||
lambda: service.begin_observation(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
started_at=_parse_timestamp(started_at),
|
||||
window_ends_at=_parse_timestamp(window_ends_at),
|
||||
maximum_task_expires_at=_parse_timestamp(maximum_task_expires_at),
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("rollback")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--at", "rolled_back_at", required=True)
|
||||
def rollback(tenant_id: str, expected_cas_version: int, rolled_back_at: str) -> None:
|
||||
service = _cutover_service()
|
||||
_operator_call(
|
||||
lambda: service.rollback(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=expected_cas_version,
|
||||
rolled_back_at=_parse_timestamp(rolled_back_at),
|
||||
)
|
||||
)
|
||||
_echo_json(service.status(tenant_id=tenant_id))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("status")
|
||||
@click.option("--tenant-id", required=True)
|
||||
def status(tenant_id: str) -> None:
|
||||
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("greenfield-initialize")
|
||||
@click.option("--tenant-id", required=True)
|
||||
def greenfield_initialize(tenant_id: str) -> None:
|
||||
"""Idempotently initialize one Workspace that has no KnowledgeFS state."""
|
||||
|
||||
_operator_call(lambda: _greenfield_initializer().ensure_initialized(tenant_id=tenant_id))
|
||||
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-request")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist readiness evidence; omitted means dry-run.")
|
||||
def cleanup_request(input_path: Path, apply: bool) -> None:
|
||||
payload = _read_one_jsonl(input_path, CleanupReadinessEvidenceInput)
|
||||
report = _cleanup_call(lambda: _cleanup_service().request_cleanup(payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-approve")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist four-eyes approval; omitted means dry-run.")
|
||||
def cleanup_approve(input_path: Path, apply: bool) -> None:
|
||||
payload = _read_one_jsonl(input_path, CleanupApprovalInput)
|
||||
report = _cleanup_call(lambda: _cleanup_service().approve_cleanup(payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-start")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist the irreversible fence; never runs deletion.")
|
||||
@click.option(
|
||||
"--acknowledge-irreversible",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Required with --apply; confirms rollback will be permanently closed.",
|
||||
)
|
||||
def cleanup_start(input_path: Path, apply: bool, acknowledge_irreversible: bool) -> None:
|
||||
payload = _read_one_jsonl(input_path, CleanupStartInput)
|
||||
if apply and not acknowledge_irreversible:
|
||||
raise click.UsageError("--apply requires --acknowledge-irreversible")
|
||||
report = _cleanup_call(lambda: _cleanup_service().start_cleanup(payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-complete")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist externally verified cleanup completion.")
|
||||
@click.option(
|
||||
"--acknowledge-executed",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Required with --apply; confirms the reviewed destructive bundle already executed.",
|
||||
)
|
||||
def cleanup_complete(input_path: Path, apply: bool, acknowledge_executed: bool) -> None:
|
||||
payload = _read_one_jsonl(input_path, CleanupCompletionEvidenceInput)
|
||||
if apply and not acknowledge_executed:
|
||||
raise click.UsageError("--apply requires --acknowledge-executed")
|
||||
report = _cleanup_call(lambda: _cleanup_service().complete_cleanup(payload, apply=apply))
|
||||
_echo_json(report._asdict())
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-status")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--request-id", required=True)
|
||||
def cleanup_status(tenant_id: str, request_id: str) -> None:
|
||||
_echo_json(_cleanup_call(lambda: _cleanup_service().status(tenant_id=tenant_id, request_id=request_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("repair")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--control-space-id", required=True)
|
||||
@click.option("--expected-resource-version", type=click.IntRange(min=0), required=True)
|
||||
@click.option("--knowledge-space-id", required=True)
|
||||
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
|
||||
def repair(
|
||||
tenant_id: str,
|
||||
control_space_id: str,
|
||||
expected_resource_version: int,
|
||||
knowledge_space_id: str,
|
||||
knowledge_space_revision: int,
|
||||
) -> None:
|
||||
control_space = _management_service().repair_registration(
|
||||
tenant_id=tenant_id,
|
||||
control_space_id=control_space_id,
|
||||
expected_resource_version=expected_resource_version,
|
||||
knowledge_space_id=knowledge_space_id,
|
||||
knowledge_space_revision=knowledge_space_revision,
|
||||
)
|
||||
click.echo(json.dumps({"control_space_id": control_space.id, "state": control_space.state.value}, sort_keys=True))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("orphan-report")
|
||||
@click.option("--limit", type=click.IntRange(min=1, max=10_000), default=500, show_default=True)
|
||||
def orphan_report(limit: int) -> None:
|
||||
report = KnowledgeFSOrphanReconciler(
|
||||
session_factory.get_session_maker(),
|
||||
get_knowledge_fs_lifecycle_remote(),
|
||||
).reconcile(limit=limit, apply_repairs=False)
|
||||
click.echo(json.dumps(report._asdict(), sort_keys=True))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("workspace-delete-request")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist durable deletion intents; omitted means dry-run.")
|
||||
def workspace_delete_request(tenant_id: str, apply: bool) -> None:
|
||||
"""Route every KnowledgeFS Space through the canonical lifecycle deletion path."""
|
||||
|
||||
if not apply:
|
||||
report = _management_service().dry_run(tenant_id=tenant_id)
|
||||
_echo_json(
|
||||
{
|
||||
"apply": False,
|
||||
"by_state": report.by_state,
|
||||
"tenant_id": tenant_id,
|
||||
"total": report.total,
|
||||
}
|
||||
)
|
||||
return
|
||||
results = _lifecycle_call(lambda: _lifecycle_service().request_workspace_cleanup(tenant_id=tenant_id))
|
||||
_echo_json(
|
||||
{
|
||||
"apply": True,
|
||||
"control_space_ids": [result.control_space.id for result in results],
|
||||
"operation_ids": [result.outbox.operation_id for result in results if result.outbox is not None],
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("workspace-delete-finalize")
|
||||
@click.option("--tenant-id", required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Purge terminal local control-plane rows.")
|
||||
@click.option(
|
||||
"--acknowledge-control-plane-purge",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Required with --apply after every remote Space has reached deleted.",
|
||||
)
|
||||
def workspace_delete_finalize(tenant_id: str, apply: bool, acknowledge_control_plane_purge: bool) -> None:
|
||||
"""Release the Workspace FK only after all remote deletions are terminal."""
|
||||
|
||||
service = _lifecycle_service()
|
||||
if not apply:
|
||||
_lifecycle_call(lambda: service.assert_workspace_deletion_allowed(tenant_id=tenant_id))
|
||||
_echo_json({"apply": False, "ready": True, "tenant_id": tenant_id})
|
||||
return
|
||||
if not acknowledge_control_plane_purge:
|
||||
raise click.UsageError("--apply requires --acknowledge-control-plane-purge")
|
||||
deleted = _lifecycle_call(lambda: service.finalize_workspace_deletion(tenant_id=tenant_id))
|
||||
_echo_json({"apply": True, "purged_control_spaces": deleted, "tenant_id": tenant_id})
|
||||
|
||||
|
||||
def _read_jsonl[InputT: BaseModel](
|
||||
input_path: Path, input_type: type[InputT], *, allow_empty: bool = False
|
||||
) -> tuple[InputT, ...]:
|
||||
records: list[InputT] = []
|
||||
for line_number, line in enumerate(input_path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
records.append(input_type.model_validate_json(line))
|
||||
except ValidationError as exc:
|
||||
raise click.ClickException(f"invalid strict JSONL at line {line_number}: {exc}") from exc
|
||||
if not records and not allow_empty:
|
||||
raise click.ClickException("strict JSONL input must contain at least one record")
|
||||
return tuple(records)
|
||||
|
||||
|
||||
def _read_one_jsonl[InputT: BaseModel](input_path: Path, input_type: type[InputT]) -> InputT:
|
||||
records = _read_jsonl(input_path, input_type)
|
||||
if len(records) != 1:
|
||||
raise click.ClickException("this command requires exactly one JSONL record")
|
||||
return records[0]
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(f"invalid ISO-8601 timestamp: {value}") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise click.ClickException("operator timestamps must include an explicit timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _operator_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
||||
try:
|
||||
return operation()
|
||||
except KnowledgeFSCutoverError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
def _cleanup_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
||||
try:
|
||||
return operation()
|
||||
except KnowledgeFSCleanupError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
def _lifecycle_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
||||
try:
|
||||
return operation()
|
||||
except KnowledgeFSControlSpaceLifecycleError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
def _echo_json(payload: object) -> None:
|
||||
click.echo(json.dumps(payload, default=str, sort_keys=True))
|
||||
|
||||
|
||||
def _management_service() -> KnowledgeFSControlSpaceManagementService:
|
||||
return KnowledgeFSControlSpaceManagementService(session_factory.get_session_maker())
|
||||
|
||||
|
||||
def _cutover_service() -> KnowledgeFSWorkspaceCutoverService:
|
||||
return KnowledgeFSWorkspaceCutoverService(
|
||||
session_factory.get_session_maker(),
|
||||
remote_factory=get_knowledge_fs_lifecycle_remote,
|
||||
)
|
||||
|
||||
|
||||
def _greenfield_initializer() -> KnowledgeFSWorkspaceGreenfieldInitializer:
|
||||
return KnowledgeFSWorkspaceGreenfieldInitializer(
|
||||
session_factory.get_session_maker(),
|
||||
cutover=_cutover_service(),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_service() -> KnowledgeFSCleanupService:
|
||||
return KnowledgeFSCleanupService(session_factory.get_session_maker())
|
||||
|
||||
|
||||
def _lifecycle_service() -> KnowledgeFSControlSpaceCommandService:
|
||||
return KnowledgeFSControlSpaceCommandService(session_factory.get_session_maker())
|
||||
|
||||
|
||||
__all__ = ["knowledge_fs_control_space"]
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
||||
|
||||
@@ -91,6 +92,13 @@ class DifyConfig(
|
||||
# for better readability and maintainability.
|
||||
# Thanks for your concentration and consideration.
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_knowledge_fs_production_origin(self) -> "DifyConfig":
|
||||
if self.DEPLOY_ENV.strip().upper() == "PRODUCTION":
|
||||
if self.KNOWLEDGE_FS_BASE_URL and not self._is_secure_or_loopback_origin(self.KNOWLEDGE_FS_BASE_URL):
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must use HTTPS in production unless it targets loopback")
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def settings_customise_sources(
|
||||
|
||||
@@ -1,30 +1,82 @@
|
||||
"""Configuration for the optional KnowledgeFS Console bridge."""
|
||||
"""Configuration for the optional KnowledgeFS control-plane integration."""
|
||||
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, PositiveFloat, SecretStr, field_validator, model_validator
|
||||
from pydantic import Field, PositiveFloat, PositiveInt, SecretStr, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class KnowledgeFSConfig(BaseSettings):
|
||||
"""Server-only settings for the KnowledgeFS production connection."""
|
||||
"""Server-only KnowledgeFS connection and rollout settings."""
|
||||
|
||||
KNOWLEDGE_FS_BACKGROUND_WORKER_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Publish KnowledgeFS background work to dedicated Celery queues after worker rollout.",
|
||||
)
|
||||
KNOWLEDGE_FS_BACKGROUND_ENGINE_PATH: str = Field(default="/app/knowledge-fs/celery-worker.mjs")
|
||||
KNOWLEDGE_FS_BACKGROUND_TASK_TIMEOUT_SECONDS: PositiveInt = Field(default=7200, ge=300, le=14400)
|
||||
KNOWLEDGE_FS_BACKGROUND_POLL_INTERVAL_SECONDS: PositiveInt = Field(default=5, ge=1, le=60)
|
||||
|
||||
KNOWLEDGE_FS_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Enable the private KnowledgeFS Console bridge.",
|
||||
description="Enable the KnowledgeFS control-plane product routes.",
|
||||
)
|
||||
KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Explicitly expose KnowledgeFS in Community edition for development deployments.",
|
||||
)
|
||||
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Enable delivery of durable KnowledgeFS lifecycle commands after every rollout gate is ready.",
|
||||
)
|
||||
KNOWLEDGE_FS_INTEGRATED_PROVISION_READY: bool = Field(
|
||||
default=False,
|
||||
description="Confirm that the Capability-v2 integrated provision route is deployed and verified.",
|
||||
)
|
||||
KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY: bool = Field(
|
||||
default=False,
|
||||
description="Confirm that legacy KFS ACL mutation is frozen for integrated mode.",
|
||||
)
|
||||
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS: PositiveInt = Field(default=15, le=300)
|
||||
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS: PositiveInt = Field(default=60, le=600)
|
||||
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE: PositiveInt = Field(default=25, le=1_000)
|
||||
KNOWLEDGE_FS_BASE_URL: str | None = Field(default=None, description="KnowledgeFS gateway base URL.")
|
||||
KNOWLEDGE_FS_JWT_SECRET: SecretStr | None = Field(
|
||||
default=None,
|
||||
min_length=32,
|
||||
description="Shared secret used to sign short-lived KnowledgeFS service JWTs.",
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Prepare resource-scoped Capability v2 issuance; disabled until rollout approval.",
|
||||
)
|
||||
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS: PositiveFloat = Field(default=300.0, le=3600.0, allow_inf_nan=False)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID: str | None = Field(
|
||||
default=None,
|
||||
description="Identifier for the current asymmetric Capability v2 signing key.",
|
||||
)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM: SecretStr | None = Field(
|
||||
default=None,
|
||||
description="Server-only PEM for the current Capability v2 RSA signing key.",
|
||||
)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS: str | None = Field(
|
||||
default=None,
|
||||
description="Optional public-only JWKS JSON retained during key rotation overlap.",
|
||||
)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_ISSUER: str = Field(default="dify-control-plane", min_length=1)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_AUDIENCE: str = Field(default="knowledge-fs", min_length=1)
|
||||
KNOWLEDGE_FS_CAPABILITY_V2_MAX_TTL_SECONDS: PositiveInt = Field(default=60, le=60)
|
||||
KNOWLEDGE_FS_JWKS_CACHE_MAX_AGE_SECONDS: PositiveInt = Field(default=300, le=86_400)
|
||||
KNOWLEDGE_FS_PRODUCT_MAX_RESPONSE_BYTES: PositiveInt = Field(default=4 * 1024 * 1024, le=16 * 1024 * 1024)
|
||||
KNOWLEDGE_FS_TIMEOUT_SECONDS: PositiveFloat = Field(default=10.0, le=60.0, allow_inf_nan=False)
|
||||
KNOWLEDGE_FS_RETRIEVAL_TEST_TIMEOUT_SECONDS: float = Field(
|
||||
default=75.0,
|
||||
gt=60.0,
|
||||
le=120.0,
|
||||
allow_inf_nan=False,
|
||||
description="Timeout for retrieval tests and Agent investigation triage, which have a 60-second model budget.",
|
||||
)
|
||||
|
||||
@field_validator(
|
||||
"KNOWLEDGE_FS_BASE_URL",
|
||||
"KNOWLEDGE_FS_JWT_SECRET",
|
||||
"KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID",
|
||||
"KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM",
|
||||
"KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
@@ -40,25 +92,45 @@ class KnowledgeFSConfig(BaseSettings):
|
||||
@field_validator("KNOWLEDGE_FS_BASE_URL")
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str | None) -> str | None:
|
||||
return cls._validate_origin(value, name="KNOWLEDGE_FS_BASE_URL")
|
||||
|
||||
@classmethod
|
||||
def _validate_origin(cls, value: str | None, *, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must be an absolute HTTP(S) URL")
|
||||
raise ValueError(f"{name} must be an absolute HTTP(S) URL")
|
||||
try:
|
||||
_ = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must include a valid port") from exc
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must not include credentials, query, or fragment")
|
||||
raise ValueError(f"{name} must include a valid port") from exc
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
|
||||
raise ValueError(f"{name} must be an origin without credentials, path, query, or fragment")
|
||||
return value.rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_connection(self) -> "KnowledgeFSConfig":
|
||||
if not self.KNOWLEDGE_FS_ENABLED:
|
||||
return self
|
||||
if bool(self.KNOWLEDGE_FS_BASE_URL) != bool(self.KNOWLEDGE_FS_JWT_SECRET):
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL and KNOWLEDGE_FS_JWT_SECRET must be configured together")
|
||||
if not self.KNOWLEDGE_FS_BASE_URL:
|
||||
raise ValueError("KnowledgeFS connection settings are required when the integration is enabled")
|
||||
if self.KNOWLEDGE_FS_ENABLED:
|
||||
if not self.KNOWLEDGE_FS_BASE_URL:
|
||||
raise ValueError("KnowledgeFS base URL is required when the integration is enabled")
|
||||
if not self.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED:
|
||||
raise ValueError("KnowledgeFS product routes require Capability v2 when enabled")
|
||||
if self.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED and not (
|
||||
self.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID and self.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM
|
||||
):
|
||||
raise ValueError("Capability v2 signing kid and private key are required when issuance is enabled")
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _is_secure_or_loopback_origin(value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme == "https":
|
||||
return True
|
||||
hostname = (parsed.hostname or "").rstrip(".").lower()
|
||||
if hostname == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ip_address(hostname).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -293,6 +293,13 @@ class CeleryConfig(DatabaseConfig):
|
||||
default=None,
|
||||
)
|
||||
|
||||
CELERY_BROKER_VISIBILITY_TIMEOUT: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Redis/Sentinel unacknowledged delivery visibility in seconds; set identically on all Celery processes."
|
||||
),
|
||||
)
|
||||
|
||||
CELERY_USE_SENTINEL: bool | None = Field(
|
||||
description="Whether to use Redis Sentinel for high availability.",
|
||||
default=False,
|
||||
|
||||
@@ -79,4 +79,6 @@ COOKIE_NAME_PASSPORT = "passport"
|
||||
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
|
||||
HEADER_NAME_APP_CODE = "X-App-Code"
|
||||
HEADER_NAME_PASSPORT = "X-App-Passport"
|
||||
HEADER_NAME_IDEMPOTENCY_KEY = "Idempotency-Key"
|
||||
HEADER_NAME_REQUEST_ID = "X-Request-ID"
|
||||
HEADER_NAME_CATALOG = "X-Dify-Catalog"
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, WithJsonSchema, computed_field
|
||||
|
||||
from fields.base import ResponseModel
|
||||
@@ -157,6 +158,7 @@ class BlockingUsageResponse(ResponseModel):
|
||||
|
||||
|
||||
class BlockingRetrieverResourceResponse(ResponseModel):
|
||||
knowledge_fs_citation: KnowledgeFsCitation | None = None
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: UUIDString | None = None
|
||||
|
||||
@@ -234,15 +234,9 @@ def _query_param_from_property(
|
||||
items = param_schema.get("items")
|
||||
if isinstance(items, Mapping):
|
||||
item_schema = _resolve_schema_ref(items, definitions)
|
||||
item_type = item_schema.get("type")
|
||||
if isinstance(item_type, str):
|
||||
param_doc["items"] = {"type": item_type}
|
||||
item_enum = item_schema.get("enum")
|
||||
if isinstance(item_enum, list):
|
||||
param_doc.setdefault("items", {})["enum"] = item_enum
|
||||
item_format = item_schema.get("format")
|
||||
if isinstance(item_format, str):
|
||||
param_doc.setdefault("items", {})["format"] = item_format
|
||||
item_doc = _query_param_item_schema(item_schema)
|
||||
if item_doc:
|
||||
param_doc["items"] = item_doc
|
||||
|
||||
enum = param_schema.get("enum")
|
||||
if isinstance(enum, list):
|
||||
@@ -303,6 +297,31 @@ def _query_param_from_property(
|
||||
return param_doc
|
||||
|
||||
|
||||
def _query_param_item_schema(item_schema: Mapping[str, Any]) -> dict[str, object]:
|
||||
item_doc: dict[str, object] = {}
|
||||
|
||||
for key in ("type", "format", "pattern"):
|
||||
value = item_schema.get(key)
|
||||
if isinstance(value, str):
|
||||
item_doc[key] = value
|
||||
|
||||
enum = item_schema.get("enum")
|
||||
if isinstance(enum, list):
|
||||
item_doc["enum"] = enum
|
||||
|
||||
for key in ("minLength", "maxLength"):
|
||||
value = item_schema.get(key)
|
||||
if isinstance(value, int):
|
||||
item_doc[key] = value
|
||||
|
||||
for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"):
|
||||
value = item_schema.get(key)
|
||||
if isinstance(value, int | float):
|
||||
item_doc[key] = value
|
||||
|
||||
return item_doc
|
||||
|
||||
|
||||
def _resolve_schema_ref(property_schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
ref = property_schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
|
||||
@@ -60,7 +60,6 @@ from . import (
|
||||
feature,
|
||||
human_input_form,
|
||||
init_validate,
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
onboarding,
|
||||
setup,
|
||||
@@ -147,6 +146,7 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .knowledge_fs import resources as knowledge_fs_resources
|
||||
from .snippets import snippet_workflow, snippet_workflow_draft_variable
|
||||
from .socketio import workflow as socketio_workflow
|
||||
|
||||
@@ -217,7 +217,7 @@ __all__ = [
|
||||
"human_input_form",
|
||||
"init_validate",
|
||||
"installed_app",
|
||||
"knowledge_fs_proxy",
|
||||
"knowledge_fs_resources",
|
||||
"load_balancing_config",
|
||||
"login",
|
||||
"mcp_server",
|
||||
|
||||
@@ -25,7 +25,7 @@ from services.auth.api_key_contracts import (
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError, UnknownKnowledgeSpaceIdsError
|
||||
|
||||
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
API_KEY_DELETE_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
@@ -42,7 +42,7 @@ def api_key_errors() -> Generator[None]:
|
||||
flask_restx.abort(404, message=str(error))
|
||||
except ApiKeyLimitExceededError as error:
|
||||
flask_restx.abort(400, message=str(error), custom="max_keys_exceeded")
|
||||
except UnknownDatasetIdsError as error:
|
||||
except (UnknownDatasetIdsError, UnknownKnowledgeSpaceIdsError) as error:
|
||||
flask_restx.abort(400, message=str(error))
|
||||
except NoPermissionError as error:
|
||||
flask_restx.abort(403, message=str(error))
|
||||
|
||||
@@ -283,10 +283,7 @@ class AppImportConfirmApi(Resource):
|
||||
import_service = AppDslService(session)
|
||||
# Confirm import
|
||||
account = current_user
|
||||
try:
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
@@ -32,6 +32,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.knowledge_entities import IndexingEstimate
|
||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||
from core.indexing_runner import IndexingRunner
|
||||
@@ -57,21 +58,79 @@ from libs.url_utils import normalize_api_base_url
|
||||
from machinery.context import RequestContext
|
||||
from models import Account, App, Dataset, Document, UploadFile
|
||||
from models.dataset import DatasetPermission, DatasetPermissionEnum, DatasetQuery
|
||||
from models.knowledge_fs import KnowledgeFSUpgradeJobStatus
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.app_service import AppService
|
||||
from services.dataset_knowledge_fs_upgrade_service import (
|
||||
KnowledgeFSUpgradeConflictError,
|
||||
KnowledgeFSUpgradeNotFoundError,
|
||||
KnowledgeFSUpgradeSnapshotService,
|
||||
upgrade_discovery_response,
|
||||
upgrade_job_response,
|
||||
)
|
||||
from services.dataset_ref_service import DatasetRefService
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSIdempotencyHeader,
|
||||
KnowledgeFSUpgradeDiscoveryResponse,
|
||||
KnowledgeFSUpgradeJobListResponse,
|
||||
KnowledgeFSUpgradeJobResponse,
|
||||
KnowledgeFSUpgradeRetryResponse,
|
||||
)
|
||||
|
||||
register_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse)
|
||||
|
||||
DATASET_LIST_PERMISSION_KEYS = frozenset({"dataset.preview", "dataset.acl.preview", "dataset.full_access"})
|
||||
_KNOWLEDGE_FS_UPGRADE_IDEMPOTENCY_HEADER_PARAMS = {
|
||||
"Idempotency-Key": {
|
||||
"description": "Stable key used to make the Dataset upgrade safe to retry",
|
||||
"in": "header",
|
||||
"maxLength": 255,
|
||||
"minLength": 8,
|
||||
"required": True,
|
||||
"type": "string",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _has_dataset_list_permission(permission_keys: list[str]) -> bool:
|
||||
return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys)
|
||||
|
||||
|
||||
def _dataset_list_access_scope(
|
||||
*, tenant_id: str, user: Account, session: Session, permissions: Any | None = None
|
||||
) -> tuple[list[str] | None, bool]:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return None, False
|
||||
|
||||
permissions = permissions or enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
tenant_id, user.id, session=session
|
||||
)
|
||||
whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources(tenant_id, user.id)
|
||||
has_default_readonly = _has_dataset_list_permission(
|
||||
permissions.dataset.default_permission_keys
|
||||
) or _has_dataset_list_permission(permissions.workspace.permission_keys)
|
||||
permission_dataset_ids: set[str] | None = None
|
||||
if not has_default_readonly:
|
||||
permission_dataset_ids = {
|
||||
override.resource_id
|
||||
for override in permissions.dataset.overrides
|
||||
if _has_dataset_list_permission(override.permission_keys)
|
||||
}
|
||||
include_own_datasets = False
|
||||
if whitelist_scope.unrestricted:
|
||||
filtered_dataset_ids = permission_dataset_ids
|
||||
include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys
|
||||
else:
|
||||
# A restricted dataset whitelist is the highest-priority visibility gate:
|
||||
# default readonly, per-dataset permission overrides, and own-dataset
|
||||
# management must not expose datasets outside this set.
|
||||
filtered_dataset_ids = set(whitelist_scope.resource_ids)
|
||||
accessible_dataset_ids = sorted(filtered_dataset_ids) if filtered_dataset_ids is not None else None
|
||||
return accessible_dataset_ids, include_own_datasets
|
||||
|
||||
|
||||
def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Account, session: Session) -> Dataset:
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), tenant_id, session=session)
|
||||
if dataset is None:
|
||||
@@ -84,6 +143,16 @@ def _get_accessible_dataset(dataset_id: UUID, tenant_id: str, current_user: Acco
|
||||
return dataset
|
||||
|
||||
|
||||
def _knowledge_fs_upgrade_idempotency_key() -> str:
|
||||
try:
|
||||
header = KnowledgeFSIdempotencyHeader.model_validate(
|
||||
{"idempotency-key": request.headers.get("Idempotency-Key")}
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise BadRequest("A valid Idempotency-Key header is required") from error
|
||||
return header.idempotency_key
|
||||
|
||||
|
||||
def _validate_indexing_technique(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
@@ -170,9 +239,12 @@ class IndexingEstimatePayload(BaseModel):
|
||||
|
||||
|
||||
class DatasetApiKeyCreatePayload(BaseModel):
|
||||
# Knowledge bases to scope the key to. Absent/empty => the key can access every
|
||||
# dataset in the tenant (default). Declared so the generated client can send it.
|
||||
# Knowledge bases to scope the key to. Both lists absent/empty => the key can access
|
||||
# every knowledge base in the tenant (default). Legacy datasets and KnowledgeFS spaces
|
||||
# live in different tables, so they are listed separately. Declared so the generated
|
||||
# client can send them.
|
||||
dataset_ids: list[str] = Field(default_factory=list)
|
||||
knowledge_space_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConsoleDatasetListQuery(BaseModel):
|
||||
@@ -182,10 +254,16 @@ class ConsoleDatasetListQuery(BaseModel):
|
||||
include_all: bool = Field(default=False, description="Include all datasets")
|
||||
ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
|
||||
creator_ids: list[Annotated[str, Field(min_length=1, max_length=255)]] = Field(
|
||||
default_factory=list,
|
||||
max_length=100,
|
||||
description="Filter by creator account IDs",
|
||||
)
|
||||
|
||||
|
||||
class DatasetListItemResponse(DatasetDetailResponse):
|
||||
partial_member_list: list[str]
|
||||
knowledge_fs_upgrade: KnowledgeFSUpgradeDiscoveryResponse
|
||||
|
||||
|
||||
class DatasetListResponse(ResponseModel):
|
||||
@@ -366,6 +444,10 @@ register_response_schema_models(
|
||||
RetrievalSettingResponse,
|
||||
PartialMemberListResponse,
|
||||
AutoDisableLogsResponse,
|
||||
KnowledgeFSUpgradeJobResponse,
|
||||
KnowledgeFSUpgradeRetryResponse,
|
||||
KnowledgeFSUpgradeDiscoveryResponse,
|
||||
KnowledgeFSUpgradeJobListResponse,
|
||||
)
|
||||
|
||||
|
||||
@@ -463,46 +545,21 @@ class DatasetListApi(Resource):
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
# Convert query parameters to dict, handling list parameters correctly
|
||||
query_params: dict[str, str | list[str]] = dict(request.args.to_dict())
|
||||
# Handle ids and tag_ids as lists (Flask request.args.getlist returns list even for single value)
|
||||
# Handle list fields explicitly (Flask request.args.getlist returns a list even for one value).
|
||||
if "ids" in request.args:
|
||||
query_params["ids"] = request.args.getlist("ids")
|
||||
if "tag_ids" in request.args:
|
||||
query_params["tag_ids"] = request.args.getlist("tag_ids")
|
||||
if "creator_ids" in request.args:
|
||||
query_params["creator_ids"] = request.args.getlist("creator_ids")
|
||||
query = ConsoleDatasetListQuery.model_validate(query_params)
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
session=session,
|
||||
str(current_tenant_id), current_user.id, session=session
|
||||
)
|
||||
accessible_dataset_ids, include_own_datasets = _dataset_list_access_scope(
|
||||
tenant_id=str(current_tenant_id), user=current_user, session=session, permissions=permissions
|
||||
)
|
||||
|
||||
accessible_dataset_ids: list[str] | None = None
|
||||
include_own_datasets = False
|
||||
if dify_config.RBAC_ENABLED:
|
||||
whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
)
|
||||
has_default_readonly = _has_dataset_list_permission(
|
||||
permissions.dataset.default_permission_keys
|
||||
) or _has_dataset_list_permission(permissions.workspace.permission_keys)
|
||||
permission_dataset_ids: set[str] | None = None
|
||||
if not has_default_readonly:
|
||||
permission_dataset_ids = {
|
||||
override.resource_id
|
||||
for override in permissions.dataset.overrides
|
||||
if _has_dataset_list_permission(override.permission_keys)
|
||||
}
|
||||
if getattr(whitelist_scope, "unrestricted", False):
|
||||
filtered_dataset_ids = permission_dataset_ids
|
||||
include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys
|
||||
else:
|
||||
# A restricted dataset whitelist is the highest-priority visibility gate:
|
||||
# default readonly, per-dataset permission overrides, and own-dataset
|
||||
# management must not expose datasets outside this set.
|
||||
filtered_dataset_ids = set(whitelist_scope.resource_ids)
|
||||
if filtered_dataset_ids is not None:
|
||||
accessible_dataset_ids = sorted(filtered_dataset_ids)
|
||||
|
||||
effective_page, effective_limit = clamp_pagination(query.page, query.limit, 100)
|
||||
if query.ids:
|
||||
@@ -527,6 +584,7 @@ class DatasetListApi(Resource):
|
||||
query.keyword,
|
||||
query.tag_ids,
|
||||
query.include_all,
|
||||
creator_ids=query.creator_ids,
|
||||
accessible_dataset_ids=accessible_dataset_ids,
|
||||
include_own_datasets=include_own_datasets,
|
||||
)
|
||||
@@ -554,6 +612,11 @@ class DatasetListApi(Resource):
|
||||
)
|
||||
for dataset in datasets
|
||||
]
|
||||
upgrade_jobs = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker()).get_latest_by_dataset_ids(
|
||||
tenant_id=current_tenant_id,
|
||||
dataset_ids=[str(dataset.id) for dataset in datasets],
|
||||
session=session,
|
||||
)
|
||||
dataset_ids = [item["id"] for item in data if item.get("permission") == "partial_members"]
|
||||
partial_members_map: dict[str, list[str]] = {}
|
||||
if dataset_ids:
|
||||
@@ -583,6 +646,11 @@ class DatasetListApi(Resource):
|
||||
else:
|
||||
item.update({"partial_member_list": []})
|
||||
item["permission_keys"] = permission_keys_map.get(str(item["id"]), [])
|
||||
item["knowledge_fs_upgrade"] = upgrade_discovery_response(
|
||||
upgrade_jobs.get(str(item["id"])),
|
||||
feature_enabled=dify_config.KNOWLEDGE_FS_ENABLED,
|
||||
dataset_provider=str(item["provider"]),
|
||||
).model_dump(mode="json")
|
||||
|
||||
response = {
|
||||
"data": data,
|
||||
@@ -824,6 +892,199 @@ class DatasetApi(Resource):
|
||||
raise DatasetInUseError()
|
||||
|
||||
|
||||
@console_ns.route("/datasets/knowledge-fs-upgrade-jobs")
|
||||
class DatasetKnowledgeFSUpgradeJobsApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Recoverable upgrade jobs from the Classic Knowledge Base to an Agent Knowledge Base",
|
||||
console_ns.models[KnowledgeFSUpgradeJobListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, Workspace()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
jobs = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker()).list_by_statuses(
|
||||
tenant_id=current_tenant_id,
|
||||
statuses=(
|
||||
KnowledgeFSUpgradeJobStatus.QUEUED,
|
||||
KnowledgeFSUpgradeJobStatus.RUNNING,
|
||||
KnowledgeFSUpgradeJobStatus.FAILED,
|
||||
),
|
||||
)
|
||||
dataset_ids = list(dict.fromkeys(job.old_dataset_id for job in jobs))
|
||||
accessible_dataset_ids, include_own_datasets = _dataset_list_access_scope(
|
||||
tenant_id=current_tenant_id, user=current_user, session=session
|
||||
)
|
||||
datasets, _ = DatasetService.get_datasets_by_ids(
|
||||
dataset_ids,
|
||||
current_tenant_id,
|
||||
user=current_user,
|
||||
accessible_dataset_ids=accessible_dataset_ids,
|
||||
include_own_datasets=include_own_datasets,
|
||||
session=session,
|
||||
)
|
||||
allowed_dataset_ids: set[str] = set()
|
||||
for dataset in datasets:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError:
|
||||
continue
|
||||
allowed_dataset_ids.add(str(dataset.id))
|
||||
return dump_response(
|
||||
KnowledgeFSUpgradeJobListResponse,
|
||||
{"data": [upgrade_job_response(job) for job in jobs if job.old_dataset_id in allowed_dataset_ids]},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/knowledge-fs-upgrades")
|
||||
class DatasetKnowledgeFSUpgradeApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Upgrade availability from the Classic Knowledge Base to an Agent Knowledge Base",
|
||||
console_ns.models[KnowledgeFSUpgradeDiscoveryResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
with session_factory.create_session() as session:
|
||||
dataset = _get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
|
||||
job = snapshots.get_latest(tenant_id=current_tenant_id, dataset_id=str(dataset_id))
|
||||
return dump_response(
|
||||
KnowledgeFSUpgradeDiscoveryResponse,
|
||||
upgrade_discovery_response(
|
||||
job,
|
||||
feature_enabled=dify_config.KNOWLEDGE_FS_ENABLED,
|
||||
dataset_provider=dataset.provider,
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.doc(params=_KNOWLEDGE_FS_UPGRADE_IDEMPOTENCY_HEADER_PARAMS)
|
||||
@console_ns.response(
|
||||
202,
|
||||
"Upgrade from the Classic Knowledge Base to an Agent Knowledge Base accepted",
|
||||
console_ns.models[KnowledgeFSUpgradeJobResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID):
|
||||
if not dify_config.KNOWLEDGE_FS_ENABLED:
|
||||
raise NotFound()
|
||||
dataset_id_str = str(dataset_id)
|
||||
with session_factory.create_session() as session:
|
||||
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
except services.errors.account.NoPermissionError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_operator):
|
||||
raise Forbidden()
|
||||
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
|
||||
try:
|
||||
job = snapshots.create(
|
||||
tenant_id=current_tenant_id,
|
||||
dataset_id=dataset_id_str,
|
||||
requested_by_account_id=current_user.id,
|
||||
idempotency_key=_knowledge_fs_upgrade_idempotency_key(),
|
||||
)
|
||||
except KnowledgeFSUpgradeNotFoundError as error:
|
||||
raise NotFound(str(error)) from error
|
||||
except KnowledgeFSUpgradeConflictError as error:
|
||||
raise Conflict(str(error)) from error
|
||||
_enqueue_upgrade_job(snapshots, tenant_id=current_tenant_id, job_id=job.id)
|
||||
return dump_response(KnowledgeFSUpgradeJobResponse, upgrade_job_response(job)), 202
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/knowledge-fs-upgrades/<string:job_id>")
|
||||
class DatasetKnowledgeFSUpgradeJobApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Status of the upgrade from the Classic Knowledge Base to an Agent Knowledge Base",
|
||||
console_ns.models[KnowledgeFSUpgradeJobResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, job_id: str):
|
||||
with session_factory.create_session() as session:
|
||||
_get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
|
||||
try:
|
||||
job = snapshots.get(tenant_id=current_tenant_id, job_id=job_id)
|
||||
except KnowledgeFSUpgradeNotFoundError as error:
|
||||
raise NotFound(str(error)) from error
|
||||
if job.old_dataset_id != str(dataset_id):
|
||||
raise NotFound("Upgrade job was not found")
|
||||
return dump_response(KnowledgeFSUpgradeJobResponse, upgrade_job_response(job))
|
||||
|
||||
@console_ns.response(
|
||||
202,
|
||||
"Retry of the upgrade from the Classic Knowledge Base to an Agent Knowledge Base accepted",
|
||||
console_ns.models[KnowledgeFSUpgradeRetryResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, job_id: str):
|
||||
with session_factory.create_session() as session:
|
||||
_get_accessible_dataset(dataset_id, current_tenant_id, current_user, session)
|
||||
if not dify_config.RBAC_ENABLED and not (
|
||||
current_user.has_edit_permission or current_user.is_dataset_operator
|
||||
):
|
||||
raise Forbidden()
|
||||
snapshots = KnowledgeFSUpgradeSnapshotService(session_factory.get_session_maker())
|
||||
try:
|
||||
job = snapshots.retry(tenant_id=current_tenant_id, job_id=job_id)
|
||||
except KnowledgeFSUpgradeNotFoundError as error:
|
||||
raise NotFound(str(error)) from error
|
||||
except KnowledgeFSUpgradeConflictError as error:
|
||||
raise Conflict(str(error)) from error
|
||||
if job.old_dataset_id != str(dataset_id):
|
||||
raise NotFound("Upgrade job was not found")
|
||||
_enqueue_upgrade_job(snapshots, tenant_id=current_tenant_id, job_id=job.id)
|
||||
return dump_response(KnowledgeFSUpgradeRetryResponse, {"id": job.id, "status": "queued"}), 202
|
||||
|
||||
|
||||
def _enqueue_upgrade_job(
|
||||
snapshots: KnowledgeFSUpgradeSnapshotService,
|
||||
*,
|
||||
tenant_id: str,
|
||||
job_id: str,
|
||||
) -> None:
|
||||
from tasks.knowledge_fs_upgrade_tasks import run_knowledge_fs_upgrade
|
||||
|
||||
task_id = str(uuid4())
|
||||
if not snapshots.claim_enqueue(tenant_id=tenant_id, job_id=job_id, task_id=task_id):
|
||||
return
|
||||
try:
|
||||
run_knowledge_fs_upgrade.apply_async(kwargs={"job_id": job_id}, task_id=task_id)
|
||||
except Exception:
|
||||
snapshots.release_enqueue_claim(tenant_id=tenant_id, job_id=job_id, task_id=task_id)
|
||||
raise
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/use-check")
|
||||
class DatasetUseCheckApi(Resource):
|
||||
@console_ns.doc("check_dataset_use")
|
||||
@@ -1135,13 +1396,22 @@ class DatasetApiKeyApi(Resource):
|
||||
def post(self, request_context: RequestContext):
|
||||
# Preserve the optional scope and validation response of the workspace route.
|
||||
payload = request.get_json(silent=True) or {}
|
||||
raw_dataset_ids = payload.get("dataset_ids") or []
|
||||
if not isinstance(raw_dataset_ids, list) or any(not isinstance(item, str) for item in raw_dataset_ids):
|
||||
console_ns.abort(400, message="dataset_ids must be a list of strings.")
|
||||
raw_dataset_ids = self._id_list(payload, "dataset_ids")
|
||||
knowledge_space_ids = self._id_list(payload, "knowledge_space_ids")
|
||||
with api_key_errors():
|
||||
key = application_services().dataset_api_keys.create_workspace_key(request_context, tuple(raw_dataset_ids))
|
||||
key = application_services().dataset_api_keys.create_workspace_key(
|
||||
request_context, tuple(raw_dataset_ids), knowledge_space_ids=tuple(knowledge_space_ids)
|
||||
)
|
||||
return dump_response(ApiKeyItem, key), 200
|
||||
|
||||
@staticmethod
|
||||
def _id_list(payload: object, key: str) -> list[str]:
|
||||
raw = payload.get(key) if isinstance(payload, dict) else None
|
||||
raw = raw or []
|
||||
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
|
||||
console_ns.abort(400, message=f"{key} must be a list of strings.")
|
||||
return list(dict.fromkeys(raw))
|
||||
|
||||
|
||||
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
|
||||
class DatasetApiDeleteApi(Resource):
|
||||
|
||||
5
api/controllers/console/knowledge_fs/__init__.py
Normal file
5
api/controllers/console/knowledge_fs/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Typed Dify-owned KnowledgeFS Console product API."""
|
||||
|
||||
from . import resources
|
||||
|
||||
__all__ = ["resources"]
|
||||
85
api/controllers/console/knowledge_fs/error.py
Normal file
85
api/controllers/console/knowledge_fs/error.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Stable, non-enumerating Console error contract for KnowledgeFS."""
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.knowledge_fs.product_dto import KnowledgeFSPublicFailureResponse
|
||||
|
||||
|
||||
class _KnowledgeFSFailureHTTPError(BaseHTTPException):
|
||||
def __init__(self, failure: KnowledgeFSPublicFailureResponse | None = None) -> None:
|
||||
super().__init__(failure.message if failure else None)
|
||||
if failure is not None and self.data is not None:
|
||||
self.data["failure"] = failure.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class KnowledgeFSSpaceNotFoundHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_space_not_found"
|
||||
description = "Agent Knowledge Base was not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class KnowledgeFSResourceNotFoundHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_resource_not_found"
|
||||
description = "Agent Knowledge Base resource was not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class KnowledgeFSOperationUnavailableHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_operation_unavailable"
|
||||
description = "Agent Knowledge Base operation is not available."
|
||||
code = 503
|
||||
|
||||
|
||||
class KnowledgeFSUpstreamUnavailableHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_upstream_unavailable"
|
||||
description = "Agent Knowledge Base is unavailable."
|
||||
code = 503
|
||||
|
||||
|
||||
class KnowledgeFSInvalidRequestHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_invalid_request"
|
||||
description = "Agent Knowledge Base request is invalid."
|
||||
code = 400
|
||||
|
||||
|
||||
class KnowledgeFSAccessDeniedHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_access_denied"
|
||||
description = "Agent Knowledge Base operation is not allowed."
|
||||
code = 403
|
||||
|
||||
|
||||
class KnowledgeFSConflictHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_conflict"
|
||||
description = "Agent Knowledge Base operation conflicts with the current resource state."
|
||||
code = 409
|
||||
|
||||
|
||||
class KnowledgeFSRequestTooLargeHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_request_too_large"
|
||||
description = "Agent Knowledge Base request is too large."
|
||||
code = 413
|
||||
|
||||
|
||||
class KnowledgeFSRequestRejectedHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_request_rejected"
|
||||
description = "Agent Knowledge Base request was rejected."
|
||||
code = 422
|
||||
|
||||
|
||||
class KnowledgeFSRateLimitHTTPError(_KnowledgeFSFailureHTTPError):
|
||||
error_code = "knowledge_fs_rate_limit_exceeded"
|
||||
description = "Agent Knowledge Base request rate limit exceeded."
|
||||
code = 429
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeFSAccessDeniedHTTPError",
|
||||
"KnowledgeFSConflictHTTPError",
|
||||
"KnowledgeFSInvalidRequestHTTPError",
|
||||
"KnowledgeFSOperationUnavailableHTTPError",
|
||||
"KnowledgeFSRateLimitHTTPError",
|
||||
"KnowledgeFSRequestRejectedHTTPError",
|
||||
"KnowledgeFSRequestTooLargeHTTPError",
|
||||
"KnowledgeFSResourceNotFoundHTTPError",
|
||||
"KnowledgeFSSpaceNotFoundHTTPError",
|
||||
"KnowledgeFSUpstreamUnavailableHTTPError",
|
||||
]
|
||||
4107
api/controllers/console/knowledge_fs/resources.py
Normal file
4107
api/controllers/console/knowledge_fs/resources.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,419 +0,0 @@
|
||||
"""Authenticated transport adapter for the Console-to-KnowledgeFS proxy.
|
||||
|
||||
These raw Blueprint routes deliberately stay outside Dify's OpenAPI surface:
|
||||
KnowledgeFS owns the wire contract consumed by the frontend. The catch-all path
|
||||
avoids resource-specific Dify controllers, while the forwarding module consumes
|
||||
only the operations explicitly enabled by Dify's product registry. The registry
|
||||
can be validated explicitly against the pinned KnowledgeFS contract during development.
|
||||
Console auth and contract-specific dataset RBAC run before forwarding. Request
|
||||
bodies are capped at 64 MiB, JSON and binary responses have separate bounds,
|
||||
SSE responses remain streaming with a bounded idle read timeout, and only safe
|
||||
response headers are exposed. Operation-specific upstream error mappings are
|
||||
applied before Console JSON error handling; the default maps 401 to 502 so it
|
||||
cannot trigger browser-session recovery and preserves resource-level 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from typing import NoReturn, cast
|
||||
|
||||
import httpx
|
||||
from flask import Response, request, stream_with_context
|
||||
from flask.typing import ResponseReturnValue
|
||||
from werkzeug.exceptions import (
|
||||
BadGateway,
|
||||
Forbidden,
|
||||
GatewayTimeout,
|
||||
HTTPException,
|
||||
NotFound,
|
||||
RequestEntityTooLarge,
|
||||
ServiceUnavailable,
|
||||
default_exceptions,
|
||||
)
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console import api, bp
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
setup_required,
|
||||
)
|
||||
from core.helper import ssrf_proxy
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.knowledge_fs_operations import KnowledgeFSMethod
|
||||
from services.knowledge_fs_proxy import (
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSAuthorization,
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSTimeoutError,
|
||||
KnowledgeFSTransportError,
|
||||
KnowledgeFSUpstreamResponse,
|
||||
authorize_knowledge_fs_request,
|
||||
get_knowledge_fs_operation,
|
||||
proxy_authorized_knowledge_fs_request,
|
||||
proxy_knowledge_fs_request,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
type _KnowledgeFSRequestForwarder = Callable[
|
||||
[str | None, str | None, bytes | None, bytes | None],
|
||||
KnowledgeFSUpstreamResponse,
|
||||
]
|
||||
|
||||
_MAX_PROXY_BODY_BYTES = 64 * 1024 * 1024
|
||||
_RESPONSE_HEADER_ALLOWLIST = (
|
||||
"Cache-Control",
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"Retry-After",
|
||||
"X-Trace-Id",
|
||||
)
|
||||
_RESPONSE_HEADER_DENYLIST = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"connection",
|
||||
"cookie",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _console_api_errors[**P](
|
||||
view: Callable[P, ResponseReturnValue],
|
||||
) -> Callable[P, ResponseReturnValue]:
|
||||
"""Route raw Blueprint exceptions through the Console API JSON handlers."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
|
||||
try:
|
||||
return view(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
return api.handle_error(exc)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _knowledge_fs_enabled[**P](
|
||||
view: Callable[P, ResponseReturnValue],
|
||||
) -> Callable[P, ResponseReturnValue]:
|
||||
"""Hide the complete KnowledgeFS route surface while the bridge is disabled."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
|
||||
if not dify_config.KNOWLEDGE_FS_ENABLED:
|
||||
raise NotFound()
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _translate_proxy_error(exc: Exception, *, tenant_id: str) -> NoReturn:
|
||||
"""Map forwarding failures to the stable Console HTTP error surface."""
|
||||
if isinstance(exc, KnowledgeFSRouteNotAllowedError):
|
||||
raise NotFound() from exc
|
||||
if isinstance(exc, KnowledgeFSAccessDeniedError):
|
||||
raise Forbidden() from exc
|
||||
if isinstance(exc, KnowledgeFSConfigurationError):
|
||||
logger.error("KnowledgeFS request was blocked by invalid configuration for tenant_id=%s", tenant_id)
|
||||
raise ServiceUnavailable("KnowledgeFS integration is misconfigured") from exc
|
||||
if isinstance(exc, KnowledgeFSTimeoutError):
|
||||
raise GatewayTimeout("KnowledgeFS request timed out") from exc
|
||||
if isinstance(exc, KnowledgeFSTransportError):
|
||||
logger.warning("KnowledgeFS transport request failed for tenant_id=%s", tenant_id)
|
||||
raise BadGateway("KnowledgeFS is unavailable") from exc
|
||||
raise exc
|
||||
|
||||
|
||||
def _knowledge_fs_operation_access_required(
|
||||
view: Callable[[KnowledgeFSAuthorization], ResponseReturnValue],
|
||||
) -> Callable[[KnowledgeFSMethod, str], ResponseReturnValue]:
|
||||
"""Authorize one declared operation before billing and request-body work."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(method: KnowledgeFSMethod, upstream_path: str) -> ResponseReturnValue:
|
||||
current_user, tenant_id = current_account_with_tenant()
|
||||
try:
|
||||
authorization = authorize_knowledge_fs_request(
|
||||
account=current_user,
|
||||
tenant_id=tenant_id,
|
||||
method=method,
|
||||
path=upstream_path,
|
||||
)
|
||||
except KnowledgeFSRouteNotAllowedError as exc:
|
||||
raise NotFound() from exc
|
||||
except KnowledgeFSAccessDeniedError as exc:
|
||||
_translate_proxy_error(exc, tenant_id=tenant_id)
|
||||
return view(authorization)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _request_body() -> bytes:
|
||||
"""Read the raw body up to the proxy limit or raise RequestEntityTooLarge."""
|
||||
body = request.stream.read(_MAX_PROXY_BODY_BYTES + 1)
|
||||
if len(body) > _MAX_PROXY_BODY_BYTES:
|
||||
raise RequestEntityTooLarge("KnowledgeFS proxy request body is too large")
|
||||
return body
|
||||
|
||||
|
||||
def _stream_response_body(
|
||||
upstream: httpx.Response,
|
||||
*,
|
||||
tenant_id: str,
|
||||
max_response_bytes: int,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one bounded SSE response and always release its pooled connection."""
|
||||
total_bytes = 0
|
||||
try:
|
||||
for chunk in upstream.iter_bytes():
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > max_response_bytes:
|
||||
logger.warning("KnowledgeFS stream exceeded the proxy limit for tenant_id=%s", tenant_id)
|
||||
raise ssrf_proxy.ResponseTooLargeError(f"response exceeded {max_response_bytes} bytes")
|
||||
yield chunk
|
||||
finally:
|
||||
upstream.close()
|
||||
|
||||
|
||||
def _proxy_response(
|
||||
upstream_result: KnowledgeFSUpstreamResponse,
|
||||
*,
|
||||
tenant_id: str,
|
||||
contract_response_headers: tuple[str, ...],
|
||||
max_response_bytes: int,
|
||||
) -> Response:
|
||||
"""Expose raw content, status, and allowlisted headers from KnowledgeFS.
|
||||
|
||||
Raises:
|
||||
HTTPException: KnowledgeFS returns a status normalized by the operation contract.
|
||||
"""
|
||||
upstream = upstream_result.response
|
||||
mapped_status = dict(upstream_result.operation.error_status_map).get(upstream.status_code)
|
||||
if mapped_status is not None:
|
||||
upstream.close()
|
||||
description = "KnowledgeFS upstream request failed"
|
||||
if upstream.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
description = "KnowledgeFS authentication failed"
|
||||
logger.error(
|
||||
"KnowledgeFS rejected the Dify server credential with HTTP %s for tenant_id=%s",
|
||||
upstream.status_code,
|
||||
tenant_id,
|
||||
)
|
||||
exception_type = default_exceptions.get(mapped_status)
|
||||
if exception_type is None:
|
||||
exception = HTTPException(description)
|
||||
exception.code = mapped_status
|
||||
raise exception
|
||||
raise exception_type(description)
|
||||
|
||||
allowed_header_names = dict.fromkeys(
|
||||
name.lower() for name in (*_RESPONSE_HEADER_ALLOWLIST, *contract_response_headers)
|
||||
)
|
||||
headers = {
|
||||
name: value
|
||||
for name in allowed_header_names
|
||||
if name not in _RESPONSE_HEADER_DENYLIST
|
||||
if (value := upstream.headers.get(name)) is not None
|
||||
}
|
||||
if upstream_result.response_kind == "stream":
|
||||
response = Response(
|
||||
stream_with_context( # pyrefly: ignore[no-matching-overload]
|
||||
_stream_response_body(
|
||||
upstream,
|
||||
tenant_id=tenant_id,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
),
|
||||
status=upstream.status_code,
|
||||
headers=headers,
|
||||
)
|
||||
response.call_on_close(upstream.close)
|
||||
return response
|
||||
|
||||
try:
|
||||
content = upstream.content
|
||||
finally:
|
||||
upstream.close()
|
||||
return Response(content, status=upstream.status_code, headers=headers)
|
||||
|
||||
|
||||
def _proxy_current_request(
|
||||
*,
|
||||
method: KnowledgeFSMethod,
|
||||
tenant_id: str,
|
||||
forward: _KnowledgeFSRequestForwarder,
|
||||
) -> Response:
|
||||
"""Forward the current raw request through one preconfigured service entry."""
|
||||
if not dify_config.KNOWLEDGE_FS_ENABLED:
|
||||
raise NotFound()
|
||||
try:
|
||||
proxy_result = forward(
|
||||
request.headers.get("Accept"),
|
||||
request.content_type,
|
||||
request.query_string or None,
|
||||
_request_body() if method != "GET" else None,
|
||||
)
|
||||
except (
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSTimeoutError,
|
||||
KnowledgeFSTransportError,
|
||||
) as exc:
|
||||
_translate_proxy_error(exc, tenant_id=tenant_id)
|
||||
return _proxy_response(
|
||||
proxy_result,
|
||||
tenant_id=tenant_id,
|
||||
contract_response_headers=proxy_result.operation.response_headers,
|
||||
max_response_bytes=proxy_result.operation.max_response_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _proxy_request(
|
||||
method: KnowledgeFSMethod,
|
||||
upstream_path: str,
|
||||
) -> Response:
|
||||
"""Authorize and forward the current request through the combined service use case."""
|
||||
if not dify_config.KNOWLEDGE_FS_ENABLED:
|
||||
raise NotFound()
|
||||
current_user, tenant_id = current_account_with_tenant()
|
||||
|
||||
def forward(
|
||||
accept: str | None,
|
||||
content_type: str | None,
|
||||
query: bytes | None,
|
||||
body: bytes | None,
|
||||
) -> KnowledgeFSUpstreamResponse:
|
||||
return proxy_knowledge_fs_request(
|
||||
account=current_user,
|
||||
method=method,
|
||||
path=upstream_path,
|
||||
tenant_id=tenant_id,
|
||||
accept=accept,
|
||||
content_type=content_type,
|
||||
query=query,
|
||||
body=body,
|
||||
request_headers=request.headers,
|
||||
)
|
||||
|
||||
return _proxy_current_request(method=method, tenant_id=tenant_id, forward=forward)
|
||||
|
||||
|
||||
def _proxy_authorized_request(authorization: KnowledgeFSAuthorization) -> Response:
|
||||
"""Forward the current request using one previously authorized operation capability.
|
||||
|
||||
Args:
|
||||
authorization: Request-scoped capability produced before billing and body parsing.
|
||||
|
||||
Returns:
|
||||
The filtered response returned by KnowledgeFS.
|
||||
|
||||
Raises:
|
||||
HTTPException: The integration is disabled or forwarding fails.
|
||||
"""
|
||||
operation = authorization.operation
|
||||
tenant_id = authorization.tenant_id
|
||||
|
||||
def forward(
|
||||
accept: str | None,
|
||||
content_type: str | None,
|
||||
query: bytes | None,
|
||||
body: bytes | None,
|
||||
) -> KnowledgeFSUpstreamResponse:
|
||||
return proxy_authorized_knowledge_fs_request(
|
||||
authorization=authorization,
|
||||
accept=accept,
|
||||
content_type=content_type,
|
||||
query=query,
|
||||
body=body,
|
||||
request_headers=request.headers,
|
||||
)
|
||||
|
||||
return _proxy_current_request(method=operation.method, tenant_id=tenant_id, forward=forward)
|
||||
|
||||
|
||||
@_knowledge_fs_enabled
|
||||
@_knowledge_fs_operation_access_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def _proxy_knowledge_fs_non_get(
|
||||
authorization: KnowledgeFSAuthorization,
|
||||
) -> ResponseReturnValue:
|
||||
"""Apply knowledge billing checks to one allowlisted non-GET operation."""
|
||||
return _proxy_authorized_request(authorization)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/knowledge-fs/<path:upstream_path>",
|
||||
methods=["OPTIONS"],
|
||||
provide_automatic_options=False,
|
||||
)
|
||||
@_console_api_errors
|
||||
@_knowledge_fs_enabled
|
||||
def proxy_knowledge_fs_options(upstream_path: str) -> ResponseReturnValue:
|
||||
"""Complete a CORS preflight only for an enabled Console operation."""
|
||||
requested_method = cast(KnowledgeFSMethod, request.headers.get("Access-Control-Request-Method", "").upper())
|
||||
try:
|
||||
get_knowledge_fs_operation(requested_method, upstream_path)
|
||||
except KnowledgeFSRouteNotAllowedError as exc:
|
||||
raise NotFound() from exc
|
||||
return Response(status=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/knowledge-fs/<path:upstream_path>",
|
||||
methods=["GET"],
|
||||
provide_automatic_options=False,
|
||||
)
|
||||
@_console_api_errors
|
||||
@_knowledge_fs_enabled
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def proxy_knowledge_fs_get(upstream_path: str) -> ResponseReturnValue:
|
||||
"""Forward one authenticated, dataset-readable GET request.
|
||||
|
||||
Args:
|
||||
upstream_path: Relative KFS path captured after the Console proxy prefix.
|
||||
|
||||
Returns:
|
||||
The filtered raw KnowledgeFS response or a Console JSON error response.
|
||||
"""
|
||||
if request.method != "GET":
|
||||
raise NotFound()
|
||||
return _proxy_request("GET", upstream_path)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/knowledge-fs/<path:upstream_path>",
|
||||
methods=["DELETE", "PATCH", "POST", "PUT"],
|
||||
provide_automatic_options=False,
|
||||
)
|
||||
@_console_api_errors
|
||||
@_knowledge_fs_enabled
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def proxy_knowledge_fs_write(upstream_path: str) -> ResponseReturnValue:
|
||||
"""Forward one authenticated non-GET request under its contract access policy.
|
||||
|
||||
Args:
|
||||
upstream_path: Relative KFS path captured after the Console proxy prefix.
|
||||
|
||||
Returns:
|
||||
The filtered raw KnowledgeFS response or a Console JSON error response.
|
||||
"""
|
||||
method = cast(KnowledgeFSMethod, request.method)
|
||||
return _proxy_knowledge_fs_non_get(method, upstream_path)
|
||||
@@ -364,7 +364,13 @@ class RBACRoleItemApi(Resource):
|
||||
def put(self, role_id):
|
||||
tenant_id, account_id = _current_ids()
|
||||
request = _payload(_RoleUpsertRequest)
|
||||
role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation())
|
||||
role = svc.RBACService.KnowledgeFSRoleMutations.update_role(
|
||||
tenant_id,
|
||||
account_id,
|
||||
str(role_id),
|
||||
request.to_mutation(),
|
||||
session=db.session(),
|
||||
)
|
||||
return _dump(role)
|
||||
|
||||
@login_required
|
||||
@@ -372,7 +378,12 @@ class RBACRoleItemApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__])
|
||||
def delete(self, role_id):
|
||||
tenant_id, account_id = _current_ids()
|
||||
svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id))
|
||||
svc.RBACService.KnowledgeFSRoleMutations.delete_role(
|
||||
tenant_id,
|
||||
account_id,
|
||||
str(role_id),
|
||||
session=db.session(),
|
||||
)
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@@ -936,7 +947,7 @@ class RBACMemberRolesApi(Resource):
|
||||
tenant_id, account_id = _current_ids()
|
||||
request = _payload(_ReplaceMemberRolesRequest)
|
||||
return _dump(
|
||||
svc.RBACService.MemberRoles.replace(
|
||||
svc.RBACService.KnowledgeFSRoleMutations.replace_member_roles(
|
||||
tenant_id,
|
||||
account_id,
|
||||
str(member_id),
|
||||
|
||||
@@ -18,12 +18,16 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
|
||||
from . import mail as _mail
|
||||
from . import runtime_credentials as _runtime_credentials
|
||||
from .agent import files as _agent_files
|
||||
from .agent import knowledge as _agent_knowledge
|
||||
from .agent import llm as _agent_llm
|
||||
from .agent import runtime_usage as _agent_runtime_usage
|
||||
from .agent import tools as _agent_tools
|
||||
from .app import dsl as _app_dsl
|
||||
from .app import file_grants as _app_file_grants
|
||||
from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .knowledge_fs import background as _knowledge_fs_background
|
||||
from .knowledge_fs import storage as _knowledge_fs_storage
|
||||
from .knowledge_fs import vectors as _knowledge_fs_vectors
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import plugin as _plugin
|
||||
from .plugin import skills as _skills
|
||||
@@ -34,11 +38,15 @@ api.add_namespace(inner_api_ns)
|
||||
__all__ = [
|
||||
"_agent_config",
|
||||
"_agent_files",
|
||||
"_agent_knowledge",
|
||||
"_agent_llm",
|
||||
"_agent_runtime_usage",
|
||||
"_agent_tools",
|
||||
"_app_dsl",
|
||||
"_app_file_grants",
|
||||
"_knowledge_fs_background",
|
||||
"_knowledge_fs_storage",
|
||||
"_knowledge_fs_vectors",
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
|
||||
102
api/controllers/inner_api/agent/knowledge.py
Normal file
102
api/controllers/inner_api/agent/knowledge.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Private authorization bridge for run-scoped, read-only Agent knowledge CLI."""
|
||||
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsError, KnowledgeFsPreparedRequest, KnowledgeFsPrepareRequest
|
||||
from dify_agent.protocol.knowledge_investigation import KnowledgeInvestigationPayload
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from fields.base import ResponseModel
|
||||
from services.agent.knowledge_gateway import AgentKnowledgeGateway
|
||||
from services.agent_config_service import AgentConfigServiceError
|
||||
from services.knowledge_fs.app_admission_service import KnowledgeFSAppAdmissionError
|
||||
from services.knowledge_fs.product_authorization import KnowledgeFSProductNotFoundError
|
||||
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
|
||||
from services.knowledge_fs.query_images import KnowledgeFSQueryImageError
|
||||
from services.knowledge_fs_capability import KnowledgeFSCapabilityConfigurationError, KnowledgeFSCapabilityPolicyError
|
||||
|
||||
register_schema_models(
|
||||
inner_api_ns, KnowledgeFsPrepareRequest, KnowledgeFsPreparedRequest, KnowledgeInvestigationPayload
|
||||
)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/knowledge/prepare")
|
||||
class AgentKnowledgePrepareApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_knowledge_prepare")
|
||||
@inner_api_ns.expect(inner_api_ns.models[KnowledgeFsPrepareRequest.__name__])
|
||||
@inner_api_ns.response(200, "Private prepared command", inner_api_ns.models[KnowledgeFsPreparedRequest.__name__])
|
||||
def post(self):
|
||||
try:
|
||||
payload = KnowledgeFsPrepareRequest.model_validate(request.get_json(silent=True) or {})
|
||||
return AgentKnowledgeGateway().prepare(payload).model_dump(mode="json", exclude_none=True)
|
||||
except ValidationError:
|
||||
return {
|
||||
"code": "KNOWLEDGE_INVALID_REQUEST",
|
||||
"message": "Invalid knowledge command or execution context.",
|
||||
}, 400
|
||||
except KnowledgeFsError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except (
|
||||
KnowledgeFSAppAdmissionError,
|
||||
KnowledgeFSProductNotFoundError,
|
||||
AgentConfigServiceError,
|
||||
KnowledgeFSCapabilityPolicyError,
|
||||
PermissionError,
|
||||
):
|
||||
return {"code": "KNOWLEDGE_ACCESS_DENIED", "message": "Knowledge access is unavailable or revoked."}, 403
|
||||
except (KnowledgeFSOperationUnavailableError, KnowledgeFSCapabilityConfigurationError):
|
||||
return {"code": "KNOWLEDGE_UNAVAILABLE", "message": "KnowledgeFS capability is unavailable."}, 503
|
||||
except (KnowledgeFSQueryImageError, ValueError):
|
||||
return {
|
||||
"code": "KNOWLEDGE_INVALID_REQUEST",
|
||||
"message": "Query image or knowledge reference is unavailable.",
|
||||
}, 400
|
||||
|
||||
|
||||
class KnowledgeInvestigationAcceptedResponse(ResponseModel):
|
||||
accepted: bool
|
||||
|
||||
|
||||
register_response_schema_models(inner_api_ns, KnowledgeInvestigationAcceptedResponse)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/knowledge/investigations")
|
||||
class AgentKnowledgeInvestigationApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_knowledge_investigation")
|
||||
@inner_api_ns.response(
|
||||
202, "Investigation queued", inner_api_ns.models[KnowledgeInvestigationAcceptedResponse.__name__]
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[KnowledgeInvestigationPayload.__name__])
|
||||
def post(self):
|
||||
try:
|
||||
payload = KnowledgeInvestigationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
AgentKnowledgeGateway().authorize_investigation(payload)
|
||||
from tasks.knowledge_fs_agent_investigation_tasks import capture_agent_knowledge_investigation_task
|
||||
|
||||
# Per-space jobs allow one unavailable knowledge base to fail independently.
|
||||
# An uncertain broker acknowledgement can be retried with the same investigation ID.
|
||||
for space_id in sorted({str(a.control_space_id) for a in payload.attempts}):
|
||||
capture_agent_knowledge_investigation_task.delay(
|
||||
report=payload.model_dump(mode="json"),
|
||||
control_space_id=space_id,
|
||||
)
|
||||
return KnowledgeInvestigationAcceptedResponse(accepted=True).model_dump(mode="json"), 202
|
||||
except ValidationError:
|
||||
return {"code": "KNOWLEDGE_INVALID_REQUEST", "message": "Invalid investigation."}, 400
|
||||
except KnowledgeFsError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except (
|
||||
AgentConfigServiceError,
|
||||
KnowledgeFSAppAdmissionError,
|
||||
KnowledgeFSCapabilityPolicyError,
|
||||
PermissionError,
|
||||
):
|
||||
return {"code": "KNOWLEDGE_ACCESS_DENIED", "message": "Knowledge access is unavailable or revoked."}, 403
|
||||
1
api/controllers/inner_api/knowledge_fs/__init__.py
Normal file
1
api/controllers/inner_api/knowledge_fs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Trusted KnowledgeFS inner API endpoints."""
|
||||
50
api/controllers/inner_api/knowledge_fs/background.py
Normal file
50
api/controllers/inner_api/knowledge_fs/background.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Authenticated broker publication only; never accepts a command or document body."""
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, RequestEntityTooLarge, ServiceUnavailable
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
|
||||
from services.knowledge_fs.background_contract import BackgroundJobPayload
|
||||
from services.knowledge_fs.background_publisher import check_background_publisher_ready, publish_background_job
|
||||
|
||||
register_schema_models(inner_api_ns, BackgroundJobPayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/background/jobs")
|
||||
class KnowledgeFSBackgroundJobApi(Resource):
|
||||
@inner_api_ns.expect(inner_api_ns.models[BackgroundJobPayload.__name__])
|
||||
@inner_api_ns.response(202, "Accepted by the Celery broker")
|
||||
@knowledge_fs_inner_api_only
|
||||
def post(self) -> Response:
|
||||
request.max_content_length = 16_384
|
||||
if request.content_length is not None and request.content_length > 16_384:
|
||||
raise RequestEntityTooLarge("KnowledgeFS background locator is too large")
|
||||
try:
|
||||
job = BackgroundJobPayload.model_validate(inner_api_ns.payload or {})
|
||||
except ValidationError as error:
|
||||
raise BadRequest("Invalid KnowledgeFS background locator") from error
|
||||
try:
|
||||
publish_background_job(job)
|
||||
except ValueError as error:
|
||||
raise BadRequest("Invalid KnowledgeFS background schedule") from error
|
||||
except Exception as error:
|
||||
# A failed/uncertain publish must leave the durable outbox eligible for redelivery.
|
||||
raise ServiceUnavailable("KnowledgeFS Celery publication is unavailable") from error
|
||||
return Response(status=HTTPStatus.ACCEPTED)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/background/health")
|
||||
class KnowledgeFSBackgroundHealthApi(Resource):
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> Response:
|
||||
try:
|
||||
check_background_publisher_ready()
|
||||
except Exception as error:
|
||||
raise ServiceUnavailable("KnowledgeFS Celery publication is unavailable") from error
|
||||
return Response(status=HTTPStatus.NO_CONTENT)
|
||||
415
api/controllers/inner_api/knowledge_fs/storage.py
Normal file
415
api/controllers/inner_api/knowledge_fs/storage.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Trusted KnowledgeFS gateway to Dify's configured object-storage backend."""
|
||||
|
||||
import json
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinasciiError
|
||||
from http import HTTPStatus
|
||||
from typing import NoReturn
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response
|
||||
from services.knowledge_fs.object_storage import (
|
||||
KNOWLEDGE_FS_OBJECT_MAX_BYTES,
|
||||
KnowledgeFSObjectList,
|
||||
KnowledgeFSObjectMetadata,
|
||||
KnowledgeFSObjectStorageChecksumError,
|
||||
KnowledgeFSObjectStorageCorruptError,
|
||||
KnowledgeFSObjectStorageError,
|
||||
KnowledgeFSObjectStorageInvalidInputError,
|
||||
KnowledgeFSObjectStorageService,
|
||||
KnowledgeFSObjectStorageTooLargeError,
|
||||
KnowledgeFSObjectStorageUnavailableError,
|
||||
)
|
||||
from services.knowledge_fs.query_images import KnowledgeFSQueryImageError, load_query_image
|
||||
from services.knowledge_fs.remote_images import (
|
||||
KnowledgeFSRemoteImageError,
|
||||
load_remote_image,
|
||||
)
|
||||
|
||||
_METADATA_HEADER = "X-Knowledge-FS-Metadata"
|
||||
_CHECKSUM_HEADER = "X-Knowledge-FS-Checksum-Sha256"
|
||||
_CONTENT_TYPE_HEADER = "X-Knowledge-FS-Content-Type"
|
||||
_MAX_ENCODED_METADATA_BYTES = 128 * 1024
|
||||
_metadata_adapter = TypeAdapter(dict[str, str])
|
||||
|
||||
|
||||
class KnowledgeFSObjectStorageHttpError(BaseHTTPException):
|
||||
"""Safe HTTP representation of a KnowledgeFS storage boundary error."""
|
||||
|
||||
error_code = "knowledge_fs_object_storage_failed"
|
||||
description = "KnowledgeFS object storage request failed."
|
||||
code = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
def __init__(self, *, error_code: str, description: str, status_code: HTTPStatus) -> None:
|
||||
self.error_code = error_code
|
||||
self.description = description
|
||||
self.code = status_code
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
class _CamelCaseResponse(ResponseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
|
||||
class KnowledgeFSObjectQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(description="Logical KnowledgeFS object key")
|
||||
|
||||
|
||||
class KnowledgeFSObjectListQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
prefix: str = Field(default="", description="Logical object-key prefix")
|
||||
cursor: str | None = Field(default=None, description="Exclusive lexical key cursor")
|
||||
limit: int = Field(default=100, ge=1, le=100, description="Maximum objects to return")
|
||||
|
||||
|
||||
class KnowledgeFSObjectMetadataResponse(_CamelCaseResponse):
|
||||
checksum_sha256_base64: str
|
||||
content_type: str | None = None
|
||||
key: str
|
||||
metadata: dict[str, str]
|
||||
size_bytes: int
|
||||
|
||||
|
||||
class KnowledgeFSObjectListResponse(_CamelCaseResponse):
|
||||
objects: list[KnowledgeFSObjectMetadataResponse]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class KnowledgeFSObjectHealthResponse(ResponseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
class KnowledgeFSQueryImageQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
upload_file_id: str = Field(min_length=1)
|
||||
tenant_id: str = Field(min_length=1)
|
||||
subject_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class KnowledgeFSRemoteImageQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: str = Field(min_length=1, max_length=8_192)
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
inner_api_ns,
|
||||
KnowledgeFSObjectMetadataResponse,
|
||||
KnowledgeFSObjectListResponse,
|
||||
KnowledgeFSObjectHealthResponse,
|
||||
)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/query-image")
|
||||
class KnowledgeFSQueryImageApi(Resource):
|
||||
"""Resolve one actor-owned or workflow-granted Dify file for a bounded KFS query run."""
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSQueryImageQuery))
|
||||
@inner_api_ns.produces(["image/gif", "image/jpeg", "image/png", "image/webp"])
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> Response:
|
||||
try:
|
||||
query = KnowledgeFSQueryImageQuery.model_validate(request.args.to_dict(flat=True))
|
||||
access_grant = request.headers.get("X-Knowledge-FS-Query-Image-Grant")
|
||||
if access_grant is not None and (not access_grant or len(access_grant) > 2_048):
|
||||
raise KnowledgeFSQueryImageError("QUERY_IMAGE_GRANT_INVALID", "Workflow query image grant is invalid")
|
||||
account_id = None if access_grant else _query_image_account_id(query.subject_id)
|
||||
image = load_query_image(
|
||||
tenant_id=query.tenant_id,
|
||||
subject_id=query.subject_id,
|
||||
account_id=account_id,
|
||||
access_grant=access_grant,
|
||||
upload_file_id=query.upload_file_id,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSQueryImageError as exc:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if exc.code == "QUERY_IMAGE_NOT_FOUND"
|
||||
else HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
||||
if exc.code in {"QUERY_IMAGE_TOO_LARGE", "QUERY_IMAGE_TOTAL_TOO_LARGE"}
|
||||
else HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code=exc.code.lower(),
|
||||
description=str(exc),
|
||||
status_code=status,
|
||||
) from exc
|
||||
|
||||
response = Response(image.body, content_type=image.mime_type)
|
||||
response.content_length = image.byte_size
|
||||
response.headers["X-Query-Image-Sha256"] = image.sha256
|
||||
response.headers["X-Query-Image-Upload-File-Id"] = image.upload_file_id
|
||||
return response
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/remote-image")
|
||||
class KnowledgeFSRemoteImageApi(Resource):
|
||||
"""Resolve one bounded document image through Dify's SSRF-protected network client."""
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSRemoteImageQuery))
|
||||
@inner_api_ns.produces(["image/gif", "image/jpeg", "image/png", "image/webp"])
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> Response:
|
||||
try:
|
||||
query = KnowledgeFSRemoteImageQuery.model_validate(request.args.to_dict(flat=True))
|
||||
image = load_remote_image(query.url)
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSRemoteImageError as exc:
|
||||
raise _remote_image_http_error(exc) from exc
|
||||
|
||||
response = Response(image.body, content_type=image.mime_type)
|
||||
response.content_length = image.byte_size
|
||||
response.headers["X-Knowledge-FS-Remote-Image-Sha256"] = image.sha256
|
||||
return response
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/storage/object")
|
||||
class KnowledgeFSObjectApi(Resource):
|
||||
"""Read, write, or delete one logical KnowledgeFS object."""
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
|
||||
@inner_api_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Object stored",
|
||||
inner_api_ns.models[KnowledgeFSObjectMetadataResponse.__name__],
|
||||
)
|
||||
@knowledge_fs_inner_api_only
|
||||
def put(self) -> dict[str, object]:
|
||||
try:
|
||||
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
|
||||
metadata = _decode_metadata_header(request.headers.get(_METADATA_HEADER))
|
||||
body = request.stream.read(KNOWLEDGE_FS_OBJECT_MAX_BYTES + 1)
|
||||
if len(body) > KNOWLEDGE_FS_OBJECT_MAX_BYTES:
|
||||
raise KnowledgeFSObjectStorageTooLargeError(f"object exceeds max bytes {KNOWLEDGE_FS_OBJECT_MAX_BYTES}")
|
||||
result = KnowledgeFSObjectStorageService().put_object(
|
||||
body=body,
|
||||
checksum_sha256_base64=request.headers.get(_CHECKSUM_HEADER),
|
||||
content_type=request.headers.get(_CONTENT_TYPE_HEADER),
|
||||
key=query.key,
|
||||
metadata=metadata,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSObjectStorageError as exc:
|
||||
_raise_http_error(exc)
|
||||
return _metadata_response(result)
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
|
||||
@inner_api_ns.produces(["application/octet-stream"])
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> Response:
|
||||
try:
|
||||
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
|
||||
service = KnowledgeFSObjectStorageService()
|
||||
metadata = service.head_object(key=query.key)
|
||||
if metadata is None:
|
||||
raise _not_found_error()
|
||||
body = service.load_stream(key=query.key)
|
||||
if body is None:
|
||||
raise _not_found_error()
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSObjectStorageError as exc:
|
||||
_raise_http_error(exc)
|
||||
|
||||
response = Response(
|
||||
body,
|
||||
content_type=metadata.content_type or "application/octet-stream",
|
||||
)
|
||||
response.content_length = metadata.size_bytes
|
||||
response.headers[_CHECKSUM_HEADER] = metadata.checksum_sha256_base64
|
||||
return response
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
|
||||
@inner_api_ns.response(HTTPStatus.NO_CONTENT, "Object deleted")
|
||||
@knowledge_fs_inner_api_only
|
||||
def delete(self) -> tuple[str, int]:
|
||||
try:
|
||||
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
|
||||
KnowledgeFSObjectStorageService().delete_object(key=query.key)
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSObjectStorageError as exc:
|
||||
_raise_http_error(exc)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/storage/object/metadata")
|
||||
class KnowledgeFSObjectMetadataApi(Resource):
|
||||
"""Read portable metadata for one logical KnowledgeFS object."""
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectQuery))
|
||||
@inner_api_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Object metadata",
|
||||
inner_api_ns.models[KnowledgeFSObjectMetadataResponse.__name__],
|
||||
)
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> dict[str, object]:
|
||||
try:
|
||||
query = KnowledgeFSObjectQuery.model_validate(request.args.to_dict(flat=True))
|
||||
result = KnowledgeFSObjectStorageService().head_object(key=query.key)
|
||||
if result is None:
|
||||
raise _not_found_error()
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSObjectStorageError as exc:
|
||||
_raise_http_error(exc)
|
||||
return _metadata_response(result)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/storage/objects")
|
||||
class KnowledgeFSObjectListApi(Resource):
|
||||
"""List logical KnowledgeFS objects with bounded keyset pagination."""
|
||||
|
||||
@inner_api_ns.doc(params=query_params_from_model(KnowledgeFSObjectListQuery))
|
||||
@inner_api_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Object page",
|
||||
inner_api_ns.models[KnowledgeFSObjectListResponse.__name__],
|
||||
)
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> dict[str, object]:
|
||||
try:
|
||||
query = KnowledgeFSObjectListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
result = KnowledgeFSObjectStorageService().list_objects(
|
||||
cursor=query.cursor,
|
||||
limit=query.limit,
|
||||
prefix=query.prefix,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise _invalid_request_error() from exc
|
||||
except KnowledgeFSObjectStorageError as exc:
|
||||
_raise_http_error(exc)
|
||||
return _list_response(result)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/storage/health")
|
||||
class KnowledgeFSObjectHealthApi(Resource):
|
||||
"""Report whether Dify storage satisfies KnowledgeFS portable requirements."""
|
||||
|
||||
@inner_api_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Storage available",
|
||||
inner_api_ns.models[KnowledgeFSObjectHealthResponse.__name__],
|
||||
)
|
||||
@inner_api_ns.response(HTTPStatus.SERVICE_UNAVAILABLE, "Storage unavailable")
|
||||
@knowledge_fs_inner_api_only
|
||||
def get(self) -> dict[str, bool] | tuple[dict[str, bool], int]:
|
||||
if KnowledgeFSObjectStorageService().health():
|
||||
return {"ok": True}
|
||||
return {"ok": False}, HTTPStatus.SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
def _decode_metadata_header(value: str | None) -> dict[str, str]:
|
||||
if value is None:
|
||||
return {}
|
||||
if len(value.encode()) > _MAX_ENCODED_METADATA_BYTES:
|
||||
raise KnowledgeFSObjectStorageInvalidInputError("object metadata header is too large")
|
||||
try:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
decoded = b64decode(value + padding, altchars=b"-_", validate=True)
|
||||
return _metadata_adapter.validate_json(decoded)
|
||||
except (BinasciiError, UnicodeEncodeError, ValidationError, json.JSONDecodeError) as exc:
|
||||
raise KnowledgeFSObjectStorageInvalidInputError("object metadata header is invalid") from exc
|
||||
|
||||
|
||||
def _query_image_account_id(subject_id: str) -> str:
|
||||
prefix = "dify-account:"
|
||||
account_id = subject_id[len(prefix) :].strip() if subject_id.startswith(prefix) else subject_id.strip()
|
||||
if not account_id:
|
||||
raise KnowledgeFSQueryImageError("QUERY_IMAGE_SUBJECT_INVALID", "Query image subject is invalid")
|
||||
return account_id
|
||||
|
||||
|
||||
def _metadata_response(metadata: KnowledgeFSObjectMetadata) -> dict[str, object]:
|
||||
return dump_response(KnowledgeFSObjectMetadataResponse, metadata)
|
||||
|
||||
|
||||
def _list_response(result: KnowledgeFSObjectList) -> dict[str, object]:
|
||||
return dump_response(KnowledgeFSObjectListResponse, result)
|
||||
|
||||
|
||||
def _raise_http_error(error: KnowledgeFSObjectStorageError) -> NoReturn:
|
||||
if isinstance(error, KnowledgeFSObjectStorageTooLargeError):
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_too_large",
|
||||
description="KnowledgeFS object exceeds the configured size limit.",
|
||||
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
) from error
|
||||
if isinstance(error, KnowledgeFSObjectStorageChecksumError):
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_checksum_mismatch",
|
||||
description="KnowledgeFS object checksum does not match the request body.",
|
||||
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
) from error
|
||||
if isinstance(error, KnowledgeFSObjectStorageInvalidInputError):
|
||||
raise _invalid_request_error() from error
|
||||
if isinstance(error, KnowledgeFSObjectStorageCorruptError):
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_corrupt",
|
||||
description="KnowledgeFS object metadata is inconsistent.",
|
||||
status_code=HTTPStatus.BAD_GATEWAY,
|
||||
) from error
|
||||
if isinstance(error, KnowledgeFSObjectStorageUnavailableError):
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_storage_unavailable",
|
||||
description="Dify object storage is unavailable for KnowledgeFS.",
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
) from error
|
||||
raise KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_storage_failed",
|
||||
description="KnowledgeFS object storage request failed.",
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
) from error
|
||||
|
||||
|
||||
def _invalid_request_error() -> KnowledgeFSObjectStorageHttpError:
|
||||
return KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_storage_invalid_request",
|
||||
description="KnowledgeFS object storage request is invalid.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def _not_found_error() -> KnowledgeFSObjectStorageHttpError:
|
||||
return KnowledgeFSObjectStorageHttpError(
|
||||
error_code="knowledge_fs_object_not_found",
|
||||
description="KnowledgeFS object was not found.",
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
def _remote_image_http_error(error: KnowledgeFSRemoteImageError) -> KnowledgeFSObjectStorageHttpError:
|
||||
status_by_code = {
|
||||
"REMOTE_IMAGE_BLOCKED": HTTPStatus.FORBIDDEN,
|
||||
"REMOTE_IMAGE_CONTENT_UNSUPPORTED": HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||||
"REMOTE_IMAGE_EMPTY": HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
"REMOTE_IMAGE_NOT_FOUND": HTTPStatus.NOT_FOUND,
|
||||
"REMOTE_IMAGE_RATE_LIMITED": HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"REMOTE_IMAGE_REQUEST_REJECTED": HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
"REMOTE_IMAGE_TOO_LARGE": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
"REMOTE_IMAGE_UPSTREAM_UNAVAILABLE": HTTPStatus.BAD_GATEWAY,
|
||||
"REMOTE_IMAGE_URL_INVALID": HTTPStatus.BAD_REQUEST,
|
||||
}
|
||||
return KnowledgeFSObjectStorageHttpError(
|
||||
error_code=error.code.lower(),
|
||||
description=str(error),
|
||||
status_code=status_by_code.get(error.code, HTTPStatus.BAD_GATEWAY),
|
||||
)
|
||||
62
api/controllers/inner_api/knowledge_fs/vectors.py
Normal file
62
api/controllers/inner_api/knowledge_fs/vectors.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Authenticated vector bridge; only the server's configured backend is reachable."""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, ServiceUnavailable
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import knowledge_fs_inner_api_only
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from services.knowledge_fs.vector_store import (
|
||||
VectorPoint,
|
||||
VectorRequest,
|
||||
admit_vector_request,
|
||||
configured_vector_client,
|
||||
execute_vector_request,
|
||||
)
|
||||
from services.tidb_binding_service import TidbBindingPendingError
|
||||
from services.vector_space_admission_service import VectorSpaceAdmissionError
|
||||
|
||||
|
||||
class VectorMatchResponse(ResponseModel):
|
||||
id: str
|
||||
score: float
|
||||
|
||||
|
||||
class VectorResponse(ResponseModel):
|
||||
points: list[VectorPoint]
|
||||
matches: list[VectorMatchResponse]
|
||||
|
||||
|
||||
register_schema_models(inner_api_ns, VectorRequest)
|
||||
register_response_schema_models(inner_api_ns, VectorResponse)
|
||||
|
||||
|
||||
@inner_api_ns.route("/knowledge-fs/vectors")
|
||||
class KnowledgeFSVectorApi(Resource):
|
||||
@inner_api_ns.expect(inner_api_ns.models[VectorRequest.__name__])
|
||||
@inner_api_ns.response(200, "Vector operation completed", inner_api_ns.models[VectorResponse.__name__])
|
||||
@knowledge_fs_inner_api_only
|
||||
def post(self) -> dict[str, object]:
|
||||
request.max_content_length = 32 * 1024 * 1024
|
||||
try:
|
||||
payload = VectorRequest.model_validate(inner_api_ns.payload or {})
|
||||
except ValidationError as error:
|
||||
raise BadRequest("Invalid KnowledgeFS vector operation") from error
|
||||
try:
|
||||
admit_vector_request(payload)
|
||||
with configured_vector_client(
|
||||
payload.scope.tenant_id, allow_create=payload.operation == "upsert"
|
||||
) as client:
|
||||
result = execute_vector_request(client, payload, check_admission=False)
|
||||
except TidbBindingPendingError:
|
||||
raise ServiceUnavailable("KnowledgeFS vector backend is being provisioned", retry_after=5) from None
|
||||
except VectorSpaceAdmissionError as error:
|
||||
raise Forbidden(str(error)) from None
|
||||
except Exception:
|
||||
# Native SDK exceptions can contain endpoint credentials or vectors.
|
||||
raise ServiceUnavailable("KnowledgeFS vector backend is unavailable") from None
|
||||
return dump_response(VectorResponse, result)
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -9,6 +11,7 @@ from controllers.inner_api.plugin.wraps import get_user_tenant, plugin_data
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation
|
||||
from core.plugin.backwards_invocation.base import BaseBackwardsInvocationResponse
|
||||
from core.plugin.backwards_invocation.datasource import PluginDatasourceBackwardsInvocation
|
||||
from core.plugin.backwards_invocation.encrypt import PluginEncrypter
|
||||
from core.plugin.backwards_invocation.model import PluginModelBackwardsInvocation
|
||||
from core.plugin.backwards_invocation.node import PluginNodeBackwardsInvocation
|
||||
@@ -16,10 +19,12 @@ from core.plugin.backwards_invocation.tool import PluginToolBackwardsInvocation
|
||||
from core.plugin.entities.request import (
|
||||
RequestFetchAppInfo,
|
||||
RequestInvokeApp,
|
||||
RequestInvokeDatasource,
|
||||
RequestInvokeEncrypt,
|
||||
RequestInvokeLLM,
|
||||
RequestInvokeLLMWithStructuredOutput,
|
||||
RequestInvokeModeration,
|
||||
RequestInvokeMultimodalEmbedding,
|
||||
RequestInvokeParameterExtractorNode,
|
||||
RequestInvokeQuestionClassifierNode,
|
||||
RequestInvokeRerank,
|
||||
@@ -28,6 +33,7 @@ from core.plugin.entities.request import (
|
||||
RequestInvokeTextEmbedding,
|
||||
RequestInvokeTool,
|
||||
RequestInvokeTTS,
|
||||
RequestListModels,
|
||||
RequestRequestDownloadFile,
|
||||
RequestRequestUploadFile,
|
||||
)
|
||||
@@ -35,6 +41,11 @@ from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import (
|
||||
InvokeConnectionError,
|
||||
InvokeRateLimitError,
|
||||
InvokeServerUnavailableError,
|
||||
)
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.helper import length_prefixed_response
|
||||
from models import Account, Tenant
|
||||
@@ -42,6 +53,48 @@ from models.model import EndUser
|
||||
from services.file_request_service import FileRequestService
|
||||
|
||||
|
||||
def _model_invocation_error_response(error: Exception) -> dict[str, object]:
|
||||
"""Return a backwards-compatible envelope with safe retry classification."""
|
||||
|
||||
message = str(error)
|
||||
normalized = message.lower()
|
||||
if (
|
||||
isinstance(error, InvokeRateLimitError)
|
||||
or "resource_exhausted" in normalized
|
||||
or "rate limit" in normalized
|
||||
or re.search(r"\b429\b", normalized)
|
||||
):
|
||||
error_code = "rate_limited"
|
||||
retryable = True
|
||||
status = 429
|
||||
elif "timed out" in normalized or "timeout" in normalized:
|
||||
error_code = "timeout"
|
||||
retryable = True
|
||||
status = 504
|
||||
elif (
|
||||
isinstance(error, InvokeConnectionError | InvokeServerUnavailableError)
|
||||
or "connection error" in normalized
|
||||
or "temporarily unavailable" in normalized
|
||||
):
|
||||
error_code = "unavailable"
|
||||
retryable = True
|
||||
status = 503
|
||||
else:
|
||||
error_code = "invocation_failed"
|
||||
retryable = False
|
||||
status = None
|
||||
|
||||
response: dict[str, object] = {
|
||||
"data": None,
|
||||
"error": message,
|
||||
"error_code": error_code,
|
||||
"retryable": retryable,
|
||||
}
|
||||
if status is not None:
|
||||
response["status"] = status
|
||||
return response
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/llm")
|
||||
class PluginInvokeLLMApi(Resource):
|
||||
@get_user_tenant
|
||||
@@ -116,6 +169,36 @@ class PluginInvokeTextEmbeddingApi(Resource):
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return _model_invocation_error_response(e)
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/multimodal-embedding")
|
||||
class PluginInvokeMultimodalEmbeddingApi(Resource):
|
||||
@get_user_tenant
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@plugin_data(payload_type=RequestInvokeMultimodalEmbedding)
|
||||
@inner_api_ns.doc("plugin_invoke_multimodal_embedding")
|
||||
@inner_api_ns.doc(description="Invoke multimodal embedding models through Dify model management")
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Multimodal embedding successful",
|
||||
401: "Unauthorized - invalid API key",
|
||||
404: "Service not available",
|
||||
}
|
||||
)
|
||||
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeMultimodalEmbedding):
|
||||
try:
|
||||
return jsonable_encoder(
|
||||
BaseBackwardsInvocationResponse(
|
||||
data=PluginModelBackwardsInvocation.invoke_multimodal_embedding(
|
||||
user_id=user_model.id,
|
||||
tenant=tenant_model,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
|
||||
|
||||
@@ -146,6 +229,70 @@ class PluginInvokeRerankApi(Resource):
|
||||
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/model-catalog")
|
||||
class PluginModelCatalogApi(Resource):
|
||||
@get_user_tenant
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@plugin_data(payload_type=RequestListModels)
|
||||
@inner_api_ns.doc("plugin_model_catalog")
|
||||
@inner_api_ns.doc(description="List tenant-active models managed by Dify")
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Model catalog lookup successful",
|
||||
401: "Unauthorized - invalid API key",
|
||||
404: "Service not available",
|
||||
}
|
||||
)
|
||||
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestListModels):
|
||||
try:
|
||||
return jsonable_encoder(
|
||||
BaseBackwardsInvocationResponse(
|
||||
data=PluginModelBackwardsInvocation.list_models(
|
||||
tenant_id=tenant_model.id,
|
||||
user_id=user_model.id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonable_encoder(BaseBackwardsInvocationResponse(error=str(e)))
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/datasource")
|
||||
class PluginInvokeDatasourceApi(Resource):
|
||||
"""Invoke an installed datasource with credentials resolved inside Dify."""
|
||||
|
||||
@get_user_tenant
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@plugin_data(payload_type=RequestInvokeDatasource)
|
||||
@inner_api_ns.doc("plugin_invoke_datasource")
|
||||
@inner_api_ns.doc(description="Invoke datasource plugins through Dify credential management")
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Datasource invocation successful (streaming response)",
|
||||
401: "Unauthorized - invalid API key",
|
||||
404: "Datasource provider, datasource, or credential not found",
|
||||
}
|
||||
)
|
||||
def post(
|
||||
self,
|
||||
user_model: Account | EndUser,
|
||||
tenant_model: Tenant,
|
||||
payload: RequestInvokeDatasource,
|
||||
):
|
||||
response = PluginDatasourceBackwardsInvocation.invoke(
|
||||
user_id=user_model.id,
|
||||
tenant=tenant_model,
|
||||
payload=payload,
|
||||
)
|
||||
return length_prefixed_response(
|
||||
0xF,
|
||||
PluginDatasourceBackwardsInvocation.convert_to_event_stream(response),
|
||||
)
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/tts")
|
||||
class PluginInvokeTTSApi(Resource):
|
||||
@get_user_tenant
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from flask import current_app, request
|
||||
from flask_login import user_logged_in
|
||||
@@ -55,14 +56,15 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
# session_id, id is auto-generated) and a fresh EndUser
|
||||
# was created per call, breaking multi-turn chat
|
||||
# continuation (see #36736).
|
||||
user_model = session.scalar(
|
||||
select(EndUser)
|
||||
.where(
|
||||
EndUser.id == user_id,
|
||||
EndUser.tenant_id == tenant_id,
|
||||
if _is_uuid(user_id):
|
||||
user_model = session.scalar(
|
||||
select(EndUser)
|
||||
.where(
|
||||
EndUser.id == user_id,
|
||||
EndUser.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if user_model is None:
|
||||
user_model = session.scalar(
|
||||
select(EndUser)
|
||||
@@ -90,6 +92,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
return user_model
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_user_tenant[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
|
||||
@@ -103,3 +103,9 @@ def agent_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
"""
|
||||
|
||||
return plugin_inner_api_only(view)
|
||||
|
||||
|
||||
def knowledge_fs_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Authenticate the trusted KnowledgeFS process on the shared inner bridge."""
|
||||
|
||||
return plugin_inner_api_only(view)
|
||||
|
||||
@@ -175,6 +175,7 @@ from . import (
|
||||
files,
|
||||
human_input_form,
|
||||
index,
|
||||
knowledge_fs,
|
||||
oauth_device,
|
||||
oauth_device_sso,
|
||||
workflow_events,
|
||||
@@ -192,6 +193,7 @@ __all__ = [
|
||||
"files",
|
||||
"human_input_form",
|
||||
"index",
|
||||
"knowledge_fs",
|
||||
"oauth_device",
|
||||
"oauth_device_sso",
|
||||
"workflow_events",
|
||||
|
||||
@@ -66,6 +66,13 @@ class OpenApiErrorCode(StrEnum):
|
||||
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
||||
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
KNOWLEDGE_FS_ACCESS_DENIED = "knowledge_fs_access_denied"
|
||||
KNOWLEDGE_FS_CONFLICT = "knowledge_fs_conflict"
|
||||
KNOWLEDGE_FS_INVALID_REQUEST = "knowledge_fs_invalid_request"
|
||||
KNOWLEDGE_FS_REQUEST_REJECTED = "knowledge_fs_request_rejected"
|
||||
KNOWLEDGE_FS_REQUEST_TOO_LARGE = "knowledge_fs_request_too_large"
|
||||
KNOWLEDGE_FS_RESOURCE_NOT_FOUND = "knowledge_fs_resource_not_found"
|
||||
KNOWLEDGE_FS_UNAVAILABLE = "knowledge_fs_unavailable"
|
||||
CATALOG_STALE = "catalog_stale"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
@@ -259,6 +266,48 @@ class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
|
||||
hint = "Action it through its channel (web app or console)."
|
||||
|
||||
|
||||
class KnowledgeFsInvalidRequestError(OpenApiError):
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_INVALID_REQUEST
|
||||
description = "The Agent Knowledge Base request is invalid."
|
||||
|
||||
|
||||
class KnowledgeFsAccessDeniedError(OpenApiError):
|
||||
code = 403
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_ACCESS_DENIED
|
||||
description = "Access to this Agent Knowledge Base is denied."
|
||||
|
||||
|
||||
class KnowledgeFsResourceNotFoundError(OpenApiError):
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_RESOURCE_NOT_FOUND
|
||||
description = "The Agent Knowledge Base or entry was not found."
|
||||
|
||||
|
||||
class KnowledgeFsConflictError(OpenApiError):
|
||||
code = 409
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_CONFLICT
|
||||
description = "The Agent Knowledge Base request conflicts with the current state."
|
||||
|
||||
|
||||
class KnowledgeFsRequestTooLargeError(OpenApiError):
|
||||
code = 413
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_REQUEST_TOO_LARGE
|
||||
description = "The Agent Knowledge Base request exceeds the allowed size."
|
||||
|
||||
|
||||
class KnowledgeFsRequestRejectedError(OpenApiError):
|
||||
code = 422
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_REQUEST_REJECTED
|
||||
description = "The Agent Knowledge Base request could not be processed."
|
||||
|
||||
|
||||
class KnowledgeFsUnavailableError(OpenApiError):
|
||||
code = 503
|
||||
error_code = OpenApiErrorCode.KNOWLEDGE_FS_UNAVAILABLE
|
||||
description = "Agent Knowledge Base is temporarily unavailable."
|
||||
|
||||
|
||||
class CatalogStale(OpenApiError): # noqa: N818
|
||||
code = 412
|
||||
error_code = OpenApiErrorCode.CATALOG_STALE
|
||||
|
||||
922
api/controllers/openapi/knowledge_fs.py
Normal file
922
api/controllers/openapi/knowledge_fs.py
Normal file
@@ -0,0 +1,922 @@
|
||||
"""Command-oriented KnowledgeFS filesystem APIs consumed by difyctl.
|
||||
|
||||
Each difyctl filesystem command maps to an independent custom method under the
|
||||
``fs`` resource. Public request and response models remain independent from the
|
||||
internal KnowledgeFS product DTOs.
|
||||
|
||||
All operations are read-only, require an OAuth account bearer with workspace
|
||||
read scope, and re-check the knowledge-space tenant boundary in the product
|
||||
facade. Deliberately indistinguishable space/path misses share one 404 code so
|
||||
callers cannot use this surface to enumerate hidden resources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint
|
||||
from controllers.openapi._errors import (
|
||||
ErrorBody,
|
||||
KnowledgeFsAccessDeniedError,
|
||||
KnowledgeFsConflictError,
|
||||
KnowledgeFsInvalidRequestError,
|
||||
KnowledgeFsRequestRejectedError,
|
||||
KnowledgeFsRequestTooLargeError,
|
||||
KnowledgeFsResourceNotFoundError,
|
||||
KnowledgeFsUnavailableError,
|
||||
)
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import CheckScope, CheckSubject, CheckWorkspaceMember
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from core.db.session_factory import session_factory
|
||||
from fields.base import ResponseModel
|
||||
from libs.oauth_bearer import Scope
|
||||
from services.knowledge_fs.data_facade import KnowledgeFSDataFacade
|
||||
from services.knowledge_fs.product_authorization import KnowledgeFSProductNotFoundError
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KNOWLEDGE_FS_PATH_PATTERN,
|
||||
KnowledgeFSCatQuery,
|
||||
KnowledgeFSCatResponse,
|
||||
KnowledgeFSConsistencyClass,
|
||||
KnowledgeFSDiffQuery,
|
||||
KnowledgeFSDiffResponse,
|
||||
KnowledgeFSFindQuery,
|
||||
KnowledgeFSGrepQuery,
|
||||
KnowledgeFSGrepResponse,
|
||||
KnowledgeFSListQuery,
|
||||
KnowledgeFSListResponse,
|
||||
KnowledgeFSResourceType,
|
||||
KnowledgeFSStatQuery,
|
||||
KnowledgeFSStatResponse,
|
||||
KnowledgeFSTreeQuery,
|
||||
KnowledgeFSTreeResponse,
|
||||
)
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSEntryResponse as ProductKnowledgeFSEntryResponse,
|
||||
)
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSGrepMatchResponse as ProductKnowledgeFSGrepMatchResponse,
|
||||
)
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSTreeNodeResponse as ProductKnowledgeFSTreeNodeResponse,
|
||||
)
|
||||
from services.knowledge_fs.product_remote import (
|
||||
KnowledgeFSOperationUnavailableError,
|
||||
KnowledgeFSProductRemoteError,
|
||||
KnowledgeFSProductRequestRejectedError,
|
||||
KnowledgeFSProductResourceNotFoundError,
|
||||
)
|
||||
from services.knowledge_fs.runtime import get_knowledge_fs_runtime
|
||||
from services.knowledge_fs_capability import KnowledgeFSCapabilityConfigurationError
|
||||
|
||||
_PATH_DESCRIPTION = "Canonical Agent Knowledge Base virtual path under /sources, /knowledge, /evidence, or /workspaces."
|
||||
_PAGE_SIZE_DESCRIPTION = "Maximum number of results to return (1-100)."
|
||||
_PAGE_TOKEN_DESCRIPTION = (
|
||||
"Opaque continuation token returned as next_page_token. Repeat the same query without inspecting the token."
|
||||
)
|
||||
_NEXT_PAGE_TOKEN_DESCRIPTION = "Opaque continuation token for the next page; null when no continuation is available."
|
||||
_CONTENT_PAGE_SIZE_DESCRIPTION = (
|
||||
"Maximum source segments to read (1-100); ignored when the selected entry has one bounded content value."
|
||||
)
|
||||
_CONSISTENCY_DESCRIPTION = "Optional Agent Knowledge Base read-consistency policy."
|
||||
_OPEN_RESPONSE_VALUE_DESCRIPTION = (
|
||||
"Open response value. Clients must tolerate unknown future values and fall back to generic display behavior."
|
||||
)
|
||||
_READ_SECURITY_DESCRIPTION = (
|
||||
"Requires an OAuth account bearer with WORKSPACE_READ. Authentication and workspace scope are checked before "
|
||||
"request validation, and Agent Knowledge Base membership is revalidated for every call. A hidden or missing "
|
||||
"Agent Knowledge Base or entry uses the same 404 response. These operations are read-only and do not emit mutation "
|
||||
"audit events."
|
||||
)
|
||||
_PAGINATION_DESCRIPTION = (
|
||||
"Results use the canonical, stable Agent Knowledge Base traversal order. The opaque next_page_token captures that "
|
||||
"order; reuse it with unchanged filters and consistency_class. total is intentionally omitted because tenant-aware "
|
||||
"visibility scans are bounded and an exact count can require an unbounded scan."
|
||||
)
|
||||
_CONTENT_CONTINUATION_DESCRIPTION = (
|
||||
"Content follows stable source order. When next_page_token is present, reuse it with the same path and "
|
||||
"consistency_class. total is not returned because this response is a bounded content stream, not a collection."
|
||||
)
|
||||
|
||||
_KNOWLEDGE_FS_ERROR_RESPONSES = {
|
||||
400: "Invalid Agent Knowledge Base request",
|
||||
401: "Missing or invalid OAuth account bearer",
|
||||
403: "Caller lacks workspace or Agent Knowledge Base read access",
|
||||
404: "Agent Knowledge Base or entry is missing or hidden",
|
||||
409: "Requested consistency conflicts with current state",
|
||||
413: "Request exceeds an Agent Knowledge Base operational bound",
|
||||
422: "Request validation failed or the request was rejected",
|
||||
503: "Agent Knowledge Base is temporarily unavailable",
|
||||
}
|
||||
|
||||
|
||||
class _KnowledgeFSEntryQuery(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=4_096, pattern=KNOWLEDGE_FS_PATH_PATTERN, description=_PATH_DESCRIPTION)
|
||||
consistency_class: KnowledgeFSConsistencyClass | None = Field(default=None, description=_CONSISTENCY_DESCRIPTION)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _KnowledgeFSPaginatedEntryQuery(_KnowledgeFSEntryQuery):
|
||||
page_size: int = Field(default=20, ge=1, le=100, description=_PAGE_SIZE_DESCRIPTION)
|
||||
page_token: str | None = Field(default=None, min_length=1, max_length=8_192, description=_PAGE_TOKEN_DESCRIPTION)
|
||||
|
||||
|
||||
class KnowledgeFSEntryListQuery(_KnowledgeFSPaginatedEntryQuery):
|
||||
"""List direct children in stable Agent Knowledge Base traversal order."""
|
||||
|
||||
|
||||
class KnowledgeFSEntryTreeQuery(_KnowledgeFSPaginatedEntryQuery):
|
||||
depth: int | None = Field(default=None, ge=1, le=8, description="Maximum tree depth (1-8).")
|
||||
|
||||
|
||||
class KnowledgeFSEntryContentSearchQuery(_KnowledgeFSPaginatedEntryQuery):
|
||||
text: str = Field(min_length=1, max_length=4_000, description="Text to find in readable entry content.")
|
||||
timeout_ms: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=10_000,
|
||||
description="Optional search time budget in milliseconds (1-10000).",
|
||||
)
|
||||
|
||||
@field_validator("text", mode="before")
|
||||
@classmethod
|
||||
def normalize_text(cls, value: object) -> object:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
class KnowledgeFSEntrySearchQuery(_KnowledgeFSPaginatedEntryQuery):
|
||||
metadata_key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
description="Exact metadata key; metadata_value must be supplied with it.",
|
||||
)
|
||||
metadata_value: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=4_000,
|
||||
description="Exact metadata value; metadata_key must be supplied with it.",
|
||||
)
|
||||
name_contains: str | None = Field(default=None, min_length=1, max_length=240)
|
||||
resource_type: KnowledgeFSResourceType | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_metadata_pair(self) -> KnowledgeFSEntrySearchQuery:
|
||||
if (self.metadata_key is None) != (self.metadata_value is None):
|
||||
raise ValueError("metadata_key and metadata_value must be supplied together")
|
||||
return self
|
||||
|
||||
|
||||
class KnowledgeFSEntryReadContentQuery(_KnowledgeFSEntryQuery):
|
||||
page_size: int = Field(default=100, ge=1, le=100, description=_CONTENT_PAGE_SIZE_DESCRIPTION)
|
||||
page_token: str | None = Field(default=None, min_length=1, max_length=8_192, description=_PAGE_TOKEN_DESCRIPTION)
|
||||
|
||||
|
||||
class KnowledgeFSEntryInspectQuery(_KnowledgeFSEntryQuery):
|
||||
pass
|
||||
|
||||
|
||||
class KnowledgeFSEntryComparePayload(BaseModel):
|
||||
old_path: str = Field(
|
||||
min_length=1,
|
||||
max_length=4_096,
|
||||
pattern=KNOWLEDGE_FS_PATH_PATTERN,
|
||||
description="Canonical path of the baseline entry.",
|
||||
)
|
||||
new_path: str = Field(
|
||||
min_length=1,
|
||||
max_length=4_096,
|
||||
pattern=KNOWLEDGE_FS_PATH_PATTERN,
|
||||
description="Canonical path of the entry to compare.",
|
||||
)
|
||||
mode: Literal["line", "word"] | None = Field(default=None, description="Comparison granularity.")
|
||||
include_semantic_summary: bool = Field(
|
||||
default=False,
|
||||
description="Whether to generate a bounded semantic change summary. This can consume model quota.",
|
||||
)
|
||||
consistency_class: KnowledgeFSConsistencyClass | None = Field(default=None, description=_CONSISTENCY_DESCRIPTION)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class KnowledgeFSEntryResponse(ResponseModel):
|
||||
kind: Literal["directory", "resource"]
|
||||
metadata: dict[str, JsonValue]
|
||||
name: str
|
||||
path: str
|
||||
resource_type: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
target_id: str | None = None
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
@classmethod
|
||||
def from_product_entry(cls, response: ProductKnowledgeFSEntryResponse) -> KnowledgeFSEntryResponse:
|
||||
return cls.model_validate(response.model_dump(mode="python"))
|
||||
|
||||
|
||||
class KnowledgeFSEntryListResponse(ResponseModel):
|
||||
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
data: list[KnowledgeFSEntryResponse]
|
||||
has_more: bool
|
||||
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
|
||||
path: str
|
||||
preview: bool | None = None
|
||||
truncated: bool = Field(
|
||||
description="Whether operational bounds made the result incomplete, even when no continuation is available."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSListResponse) -> KnowledgeFSEntryListResponse:
|
||||
return cls(
|
||||
consistency_class=response.consistency_class,
|
||||
data=[KnowledgeFSEntryResponse.from_product_entry(item) for item in response.items],
|
||||
has_more=response.next_cursor is not None,
|
||||
next_page_token=response.next_cursor,
|
||||
path=response.path,
|
||||
preview=response.preview,
|
||||
truncated=response.truncated,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSEntryTreeNodeResponse(KnowledgeFSEntryResponse):
|
||||
children: list[KnowledgeFSEntryTreeNodeResponse] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_product_tree_node(cls, response: ProductKnowledgeFSTreeNodeResponse) -> KnowledgeFSEntryTreeNodeResponse:
|
||||
return cls.model_validate(
|
||||
{
|
||||
**response.model_dump(mode="python", exclude={"children"}),
|
||||
"children": (
|
||||
None
|
||||
if response.children is None
|
||||
else [KnowledgeFSEntryTreeNodeResponse.from_product_tree_node(child) for child in response.children]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSEntryTreeResponse(ResponseModel):
|
||||
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
has_more: bool
|
||||
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
|
||||
path: str
|
||||
preview: bool | None = None
|
||||
root: KnowledgeFSEntryTreeNodeResponse
|
||||
truncated: bool = Field(
|
||||
description="Whether operational bounds made the result incomplete, even when no continuation is available."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSTreeResponse) -> KnowledgeFSEntryTreeResponse:
|
||||
return cls(
|
||||
consistency_class=response.consistency_class,
|
||||
has_more=response.next_cursor is not None,
|
||||
next_page_token=response.next_cursor,
|
||||
path=response.path,
|
||||
preview=response.preview,
|
||||
root=KnowledgeFSEntryTreeNodeResponse.from_product_tree_node(response.root),
|
||||
truncated=response.truncated,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSEntryContentMatchResponse(ResponseModel):
|
||||
end_offset: int = Field(ge=0)
|
||||
kind: Literal["node", "segment"]
|
||||
metadata: dict[str, JsonValue]
|
||||
node_id: str | None = None
|
||||
path: str
|
||||
segment_id: str | None = None
|
||||
snippet: str
|
||||
start_offset: int = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: ProductKnowledgeFSGrepMatchResponse) -> KnowledgeFSEntryContentMatchResponse:
|
||||
return cls.model_validate(response.model_dump(mode="python"))
|
||||
|
||||
|
||||
class KnowledgeFSEntryContentSearchResponse(ResponseModel):
|
||||
data: list[KnowledgeFSEntryContentMatchResponse]
|
||||
has_more: bool
|
||||
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
|
||||
path: str
|
||||
truncated: bool = Field(
|
||||
description="Whether operational bounds made the result incomplete, even when no continuation is available."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSGrepResponse) -> KnowledgeFSEntryContentSearchResponse:
|
||||
return cls(
|
||||
data=[KnowledgeFSEntryContentMatchResponse.from_product(match) for match in response.matches],
|
||||
has_more=response.next_cursor is not None,
|
||||
next_page_token=response.next_cursor,
|
||||
path=response.path,
|
||||
truncated=response.truncated,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSEntryReadContentResponse(ResponseModel):
|
||||
content_type: str
|
||||
has_more: bool
|
||||
next_page_token: str | None = Field(default=None, description=_NEXT_PAGE_TOKEN_DESCRIPTION)
|
||||
path: str
|
||||
text: str
|
||||
truncated: bool = Field(
|
||||
description="Whether operational bounds made the content incomplete, even when no continuation is available."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSCatResponse) -> KnowledgeFSEntryReadContentResponse:
|
||||
return cls(
|
||||
content_type=response.content_type,
|
||||
has_more=response.next_cursor is not None,
|
||||
next_page_token=response.next_cursor,
|
||||
path=response.path,
|
||||
text=response.text,
|
||||
truncated=response.truncated,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSEntryComparisonOperationResponse(ResponseModel):
|
||||
kind: Literal["equal", "insert", "delete"]
|
||||
new_end: int | None = Field(default=None, ge=1)
|
||||
new_start: int | None = Field(default=None, ge=1)
|
||||
old_end: int | None = Field(default=None, ge=1)
|
||||
old_start: int | None = Field(default=None, ge=1)
|
||||
text: str
|
||||
|
||||
|
||||
class KnowledgeFSEntryComparisonStatsResponse(ResponseModel):
|
||||
delete: int = Field(ge=0)
|
||||
equal: int = Field(ge=0)
|
||||
insert: int = Field(ge=0)
|
||||
|
||||
|
||||
class KnowledgeFSEntrySemanticChangeResponse(ResponseModel):
|
||||
category: str
|
||||
evidence: list[str]
|
||||
summary: str
|
||||
|
||||
|
||||
class KnowledgeFSEntrySemanticSummaryResponse(ResponseModel):
|
||||
changes: list[KnowledgeFSEntrySemanticChangeResponse]
|
||||
metadata: dict[str, JsonValue]
|
||||
model: str | None = None
|
||||
summary: str
|
||||
|
||||
|
||||
class KnowledgeFSEntryComparisonResponse(ResponseModel):
|
||||
mode: Literal["line", "word"]
|
||||
new_path: str
|
||||
old_path: str
|
||||
operations: list[KnowledgeFSEntryComparisonOperationResponse]
|
||||
semantic: KnowledgeFSEntrySemanticSummaryResponse | None = None
|
||||
stats: KnowledgeFSEntryComparisonStatsResponse
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSDiffResponse) -> KnowledgeFSEntryComparisonResponse:
|
||||
return cls.model_validate(response.model_dump(mode="python"))
|
||||
|
||||
|
||||
class KnowledgeFSEntryMetadataResponse(ResponseModel):
|
||||
consistency_class: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
content_type: str | None = None
|
||||
metadata: dict[str, JsonValue]
|
||||
parser_status: str | None = Field(default=None, description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
path: str
|
||||
preview: bool | None = None
|
||||
resource_type: str = Field(description=_OPEN_RESPONSE_VALUE_DESCRIPTION)
|
||||
sha256: str | None = None
|
||||
size_bytes: int | None = Field(default=None, ge=0)
|
||||
target_id: str
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, response: KnowledgeFSStatResponse) -> KnowledgeFSEntryMetadataResponse:
|
||||
return cls.model_validate(response.model_dump(mode="python"))
|
||||
|
||||
|
||||
register_schema_models(
|
||||
openapi_ns,
|
||||
KnowledgeFSEntryListQuery,
|
||||
KnowledgeFSEntryTreeQuery,
|
||||
KnowledgeFSEntryContentSearchQuery,
|
||||
KnowledgeFSEntrySearchQuery,
|
||||
KnowledgeFSEntryReadContentQuery,
|
||||
KnowledgeFSEntryInspectQuery,
|
||||
KnowledgeFSEntryComparePayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
openapi_ns,
|
||||
KnowledgeFSEntryListResponse,
|
||||
KnowledgeFSEntryTreeResponse,
|
||||
KnowledgeFSEntryContentSearchResponse,
|
||||
KnowledgeFSEntryReadContentResponse,
|
||||
KnowledgeFSEntryComparisonResponse,
|
||||
KnowledgeFSEntryMetadataResponse,
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_fs_facade() -> KnowledgeFSDataFacade:
|
||||
return get_knowledge_fs_runtime(session_factory.get_session_maker()).facade
|
||||
|
||||
|
||||
def _knowledge_fs_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
try:
|
||||
return view(*args, **kwargs)
|
||||
except (KnowledgeFSProductNotFoundError, KnowledgeFSProductResourceNotFoundError) as exc:
|
||||
raise KnowledgeFsResourceNotFoundError() from exc
|
||||
except KnowledgeFSProductRequestRejectedError as exc:
|
||||
if exc.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise KnowledgeFsInvalidRequestError() from exc
|
||||
if exc.status_code == HTTPStatus.CONFLICT:
|
||||
raise KnowledgeFsConflictError() from exc
|
||||
if exc.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
|
||||
raise KnowledgeFsRequestTooLargeError() from exc
|
||||
raise KnowledgeFsRequestRejectedError() from exc
|
||||
except PermissionError as exc:
|
||||
raise KnowledgeFsAccessDeniedError() from exc
|
||||
except (
|
||||
KnowledgeFSCapabilityConfigurationError,
|
||||
KnowledgeFSOperationUnavailableError,
|
||||
KnowledgeFSProductRemoteError,
|
||||
) as exc:
|
||||
raise KnowledgeFsUnavailableError() from exc
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _account_id(ctx: Context) -> str:
|
||||
return str(ctx.subject.account_id)
|
||||
|
||||
|
||||
def _facade_args(workspace_id: str, knowledge_space_id: str, ctx: Context) -> dict[str, str]:
|
||||
# The OpenAPI resource ID is resolved through Dify's control-plane record.
|
||||
# Keeping that implementation name at this adapter boundary prevents it from
|
||||
# leaking into the external path and leaves the backing lookup replaceable.
|
||||
return {
|
||||
"tenant_id": workspace_id,
|
||||
"account_id": _account_id(ctx),
|
||||
"control_space_id": knowledge_space_id,
|
||||
}
|
||||
|
||||
|
||||
def _list_product_query(query: KnowledgeFSEntryListQuery) -> KnowledgeFSListQuery:
|
||||
return KnowledgeFSListQuery(
|
||||
path=query.path,
|
||||
limit=query.page_size,
|
||||
cursor=query.page_token,
|
||||
consistency_class=query.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _tree_product_query(query: KnowledgeFSEntryTreeQuery) -> KnowledgeFSTreeQuery:
|
||||
return KnowledgeFSTreeQuery(
|
||||
path=query.path,
|
||||
limit=query.page_size,
|
||||
cursor=query.page_token,
|
||||
depth=query.depth,
|
||||
consistency_class=query.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _content_search_product_query(query: KnowledgeFSEntryContentSearchQuery) -> KnowledgeFSGrepQuery:
|
||||
return KnowledgeFSGrepQuery(
|
||||
path=query.path,
|
||||
query=query.text,
|
||||
limit=query.page_size,
|
||||
cursor=query.page_token,
|
||||
timeout_ms=query.timeout_ms,
|
||||
consistency_class=query.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _search_product_query(query: KnowledgeFSEntrySearchQuery) -> KnowledgeFSFindQuery:
|
||||
return KnowledgeFSFindQuery(
|
||||
path=query.path,
|
||||
limit=query.page_size,
|
||||
cursor=query.page_token,
|
||||
metadata_key=query.metadata_key,
|
||||
metadata_value=query.metadata_value,
|
||||
name_contains=query.name_contains,
|
||||
resource_type=query.resource_type,
|
||||
consistency_class=query.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _compare_product_query(body: KnowledgeFSEntryComparePayload) -> KnowledgeFSDiffQuery:
|
||||
return KnowledgeFSDiffQuery(
|
||||
old_path=body.old_path,
|
||||
new_path=body.new_path,
|
||||
mode=body.mode,
|
||||
semantic=body.include_semantic_summary,
|
||||
consistency_class=body.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _read_content_product_query(query: KnowledgeFSEntryReadContentQuery) -> KnowledgeFSCatQuery:
|
||||
return KnowledgeFSCatQuery(
|
||||
path=query.path,
|
||||
limit=query.page_size,
|
||||
cursor=query.page_token,
|
||||
consistency_class=query.consistency_class,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_product_query(query: KnowledgeFSEntryInspectQuery) -> KnowledgeFSStatQuery:
|
||||
return KnowledgeFSStatQuery(path=query.path, consistency_class=query.consistency_class)
|
||||
|
||||
|
||||
_FS_ROUTE = "/workspaces/<string:workspace_id>/knowledge-fs/knowledge-spaces/<string:knowledge_space_id>/fs"
|
||||
|
||||
|
||||
def _knowledge_fs_operation[**P, R](
|
||||
operation_id: str,
|
||||
*,
|
||||
summary: str,
|
||||
description: str,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Attach the stable operation identity and complete non-2xx contract."""
|
||||
|
||||
def decorator(view: Callable[P, R]) -> Callable[P, R]:
|
||||
documented = openapi_ns.doc(
|
||||
operation_id,
|
||||
summary=summary,
|
||||
description=f"{description} {_READ_SECURITY_DESCRIPTION}",
|
||||
params={
|
||||
"knowledge_space_id": "Stable Dify Agent Knowledge Base resource ID; treat it as opaque.",
|
||||
"workspace_id": "Dify workspace ID that owns the Agent Knowledge Base.",
|
||||
},
|
||||
)(view)
|
||||
for status_code, response_description in _KNOWLEDGE_FS_ERROR_RESPONSES.items():
|
||||
documented = openapi_ns.response(
|
||||
status_code,
|
||||
response_description,
|
||||
openapi_ns.models[ErrorBody.__name__],
|
||||
)(documented)
|
||||
return documented
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:ls")
|
||||
class KnowledgeFsEntryListApi(Resource):
|
||||
"""List direct child entries; default ordering is stable and encoded by the page token."""
|
||||
|
||||
@endpoint(
|
||||
op="knowledge_fs.ls",
|
||||
kind=Kind.OBJECT,
|
||||
summary="List an Agent Knowledge Base directory (ls)",
|
||||
examples=(
|
||||
Example(
|
||||
title="List a knowledge directory",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntryListQuery,
|
||||
returns=(200, KnowledgeFSEntryListResponse, "Agent Knowledge Base entry page"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"ls_knowledge_fs",
|
||||
summary="List an Agent Knowledge Base directory (ls)",
|
||||
description=(f"Lists direct child entries under path, equivalent to difyctl fs ls. {_PAGINATION_DESCRIPTION}"),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntryListQuery,
|
||||
) -> KnowledgeFSEntryListResponse:
|
||||
response = _knowledge_fs_facade().list_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_list_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryListResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:tree")
|
||||
class KnowledgeFsEntryTreeApi(Resource):
|
||||
@endpoint(
|
||||
op="knowledge_fs.tree",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Traverse an Agent Knowledge Base directory (tree)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Traverse a knowledge directory",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge",
|
||||
"depth": 2,
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntryTreeQuery,
|
||||
returns=(200, KnowledgeFSEntryTreeResponse, "Agent Knowledge Base entry tree"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"tree_knowledge_fs",
|
||||
summary="Traverse an Agent Knowledge Base directory (tree)",
|
||||
description=(
|
||||
f"Returns a depth- and page-size-bounded tree rooted at path, equivalent to difyctl fs tree. "
|
||||
f"{_PAGINATION_DESCRIPTION}"
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntryTreeQuery,
|
||||
) -> KnowledgeFSEntryTreeResponse:
|
||||
response = _knowledge_fs_facade().tree_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_tree_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryTreeResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:grep")
|
||||
class KnowledgeFsEntryContentSearchApi(Resource):
|
||||
@endpoint(
|
||||
op="knowledge_fs.grep",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Search Agent Knowledge Base content (grep)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Search knowledge content",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge",
|
||||
"text": "deployment",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntryContentSearchQuery,
|
||||
returns=(200, KnowledgeFSEntryContentSearchResponse, "Agent Knowledge Base content matches"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"grep_knowledge_fs",
|
||||
summary="Search Agent Knowledge Base content (grep)",
|
||||
description=(
|
||||
"Searches readable content beneath path, equivalent to difyctl fs grep. Matches follow canonical entry "
|
||||
f"traversal order and source-offset order within each entry. {_PAGINATION_DESCRIPTION}"
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntryContentSearchQuery,
|
||||
) -> KnowledgeFSEntryContentSearchResponse:
|
||||
response = _knowledge_fs_facade().grep_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_content_search_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryContentSearchResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:find")
|
||||
class KnowledgeFsEntrySearchApi(Resource):
|
||||
@endpoint(
|
||||
op="knowledge_fs.find",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Find Agent Knowledge Base entries (find)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Find entries by name",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge",
|
||||
"name_contains": "guide",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntrySearchQuery,
|
||||
returns=(200, KnowledgeFSEntryListResponse, "Agent Knowledge Base entry search results"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"find_knowledge_fs",
|
||||
summary="Find Agent Knowledge Base entries (find)",
|
||||
description=(
|
||||
"Searches entries beneath path by name, resource type, or an exact metadata key/value pair, equivalent "
|
||||
"to difyctl fs find. "
|
||||
f"{_PAGINATION_DESCRIPTION}"
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntrySearchQuery,
|
||||
) -> KnowledgeFSEntryListResponse:
|
||||
response = _knowledge_fs_facade().find_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_search_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryListResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:diff")
|
||||
class KnowledgeFsEntryCompareApi(Resource):
|
||||
"""Side-effect-free comparison query; semantic summaries can consume model quota on every retry."""
|
||||
|
||||
@endpoint(
|
||||
op="knowledge_fs.diff",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Compare two Agent Knowledge Base entries (diff)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Compare two knowledge entries",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"old_path": "/knowledge/guide-v1",
|
||||
"new_path": "/knowledge/guide-v2",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
body=KnowledgeFSEntryComparePayload,
|
||||
returns=(200, KnowledgeFSEntryComparisonResponse, "Agent Knowledge Base entry comparison"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"diff_knowledge_fs",
|
||||
summary="Compare two Agent Knowledge Base entries (diff)",
|
||||
description=(
|
||||
"Performs a side-effect-free comparison, equivalent to difyctl fs diff. POST is used because this is a "
|
||||
"structured query and an optional semantic summary can consume model quota. Automatic retries are not safe "
|
||||
"when include_semantic_summary=true because each retry can consume quota again."
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def post(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
body: KnowledgeFSEntryComparePayload,
|
||||
) -> KnowledgeFSEntryComparisonResponse:
|
||||
response = _knowledge_fs_facade().diff_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_compare_product_query(body),
|
||||
)
|
||||
return KnowledgeFSEntryComparisonResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:cat")
|
||||
class KnowledgeFsEntryReadContentApi(Resource):
|
||||
@endpoint(
|
||||
op="knowledge_fs.cat",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Read Agent Knowledge Base entry content (cat)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Read a knowledge entry",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge/guide",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntryReadContentQuery,
|
||||
returns=(200, KnowledgeFSEntryReadContentResponse, "Agent Knowledge Base entry content"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"cat_knowledge_fs",
|
||||
summary="Read Agent Knowledge Base entry content (cat)",
|
||||
description=(
|
||||
f"Reads a bounded text portion of one entry, equivalent to difyctl fs cat. "
|
||||
f"{_CONTENT_CONTINUATION_DESCRIPTION}"
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntryReadContentQuery,
|
||||
) -> KnowledgeFSEntryReadContentResponse:
|
||||
response = _knowledge_fs_facade().cat_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_read_content_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryReadContentResponse.from_product(response)
|
||||
|
||||
|
||||
@openapi_ns.route(f"{_FS_ROUTE}:stat")
|
||||
class KnowledgeFsEntryInspectApi(Resource):
|
||||
@endpoint(
|
||||
op="knowledge_fs.stat",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Inspect an Agent Knowledge Base entry (stat)",
|
||||
examples=(
|
||||
Example(
|
||||
title="Inspect a knowledge entry",
|
||||
input={
|
||||
"workspace_id": "<workspace_id>",
|
||||
"knowledge_space_id": "<knowledge_space_id>",
|
||||
"path": "/knowledge/guide",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckScope(Scope.WORKSPACE_READ),
|
||||
CheckWorkspaceMember(),
|
||||
),
|
||||
query=KnowledgeFSEntryInspectQuery,
|
||||
returns=(200, KnowledgeFSEntryMetadataResponse, "Agent Knowledge Base entry metadata"),
|
||||
)
|
||||
@_knowledge_fs_operation(
|
||||
"stat_knowledge_fs",
|
||||
summary="Inspect an Agent Knowledge Base entry (stat)",
|
||||
description=(
|
||||
"Returns stable metadata for one entry selected by canonical virtual path without reading content, "
|
||||
"equivalent to difyctl fs stat."
|
||||
),
|
||||
)
|
||||
@_knowledge_fs_errors
|
||||
def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
knowledge_space_id: str,
|
||||
*,
|
||||
ctx: Context,
|
||||
query: KnowledgeFSEntryInspectQuery,
|
||||
) -> KnowledgeFSEntryMetadataResponse:
|
||||
response = _knowledge_fs_facade().stat_knowledge_fs(
|
||||
**_facade_args(workspace_id, knowledge_space_id, ctx),
|
||||
query=_inspect_product_query(query),
|
||||
)
|
||||
return KnowledgeFSEntryMetadataResponse.from_product(response)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeFsEntryCompareApi",
|
||||
"KnowledgeFsEntryContentSearchApi",
|
||||
"KnowledgeFsEntryInspectApi",
|
||||
"KnowledgeFsEntryListApi",
|
||||
"KnowledgeFsEntryReadContentApi",
|
||||
"KnowledgeFsEntrySearchApi",
|
||||
"KnowledgeFsEntryTreeApi",
|
||||
]
|
||||
@@ -39,6 +39,7 @@ from .dataset import (
|
||||
)
|
||||
from .dataset.rag_pipeline import rag_pipeline_workflow
|
||||
from .end_user import end_user
|
||||
from .knowledge_fs import resources as knowledge_fs_resources
|
||||
from .workspace import models
|
||||
|
||||
__all__ = [
|
||||
@@ -55,6 +56,7 @@ __all__ = [
|
||||
"hit_testing",
|
||||
"human_input_form",
|
||||
"index",
|
||||
"knowledge_fs_resources",
|
||||
"message",
|
||||
"metadata",
|
||||
"models",
|
||||
|
||||
5
api/controllers/service_api/knowledge_fs/__init__.py
Normal file
5
api/controllers/service_api/knowledge_fs/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""KnowledgeFS-specific Service API authenticated by resource credentials."""
|
||||
|
||||
from . import resources
|
||||
|
||||
__all__ = ["resources"]
|
||||
92
api/controllers/service_api/knowledge_fs/error.py
Normal file
92
api/controllers/service_api/knowledge_fs/error.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Stable KnowledgeFS Service API error contract."""
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.knowledge_fs.product_dto import KnowledgeFSPublicFailureResponse
|
||||
|
||||
|
||||
class _KnowledgeFSServiceFailureHTTPError(BaseHTTPException):
|
||||
def __init__(self, failure: KnowledgeFSPublicFailureResponse | None = None) -> None:
|
||||
super().__init__(failure.message if failure else None)
|
||||
if failure is not None and self.data is not None:
|
||||
self.data["failure"] = failure.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class KnowledgeFSInvalidCredentialHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_invalid_credential"
|
||||
description = "Invalid Agent Knowledge Base service credential."
|
||||
code = 401
|
||||
|
||||
|
||||
class KnowledgeFSServiceOperationUnavailableHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_operation_unavailable"
|
||||
description = "Agent Knowledge Base operation is not available."
|
||||
code = 503
|
||||
|
||||
|
||||
class KnowledgeFSServiceUpstreamUnavailableHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_upstream_unavailable"
|
||||
description = "Agent Knowledge Base is unavailable."
|
||||
code = 502
|
||||
|
||||
|
||||
class KnowledgeFSServiceTimeoutHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_timeout"
|
||||
description = "Agent Knowledge Base request timed out."
|
||||
code = 504
|
||||
|
||||
|
||||
class KnowledgeFSServiceInvalidRequestHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_invalid_request"
|
||||
description = "Agent Knowledge Base request is invalid."
|
||||
code = 400
|
||||
|
||||
|
||||
class KnowledgeFSServiceAccessDeniedHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_access_denied"
|
||||
description = "Agent Knowledge Base operation is not allowed."
|
||||
code = 403
|
||||
|
||||
|
||||
class KnowledgeFSServiceResourceNotFoundHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_resource_not_found"
|
||||
description = "Agent Knowledge Base resource was not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class KnowledgeFSServiceConflictHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_conflict"
|
||||
description = "Agent Knowledge Base operation conflicts with the current resource state."
|
||||
code = 409
|
||||
|
||||
|
||||
class KnowledgeFSServiceRequestTooLargeHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_request_too_large"
|
||||
description = "Agent Knowledge Base request is too large."
|
||||
code = 413
|
||||
|
||||
|
||||
class KnowledgeFSServiceRequestRejectedHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_request_rejected"
|
||||
description = "Agent Knowledge Base request was rejected."
|
||||
code = 422
|
||||
|
||||
|
||||
class KnowledgeFSServiceRateLimitHTTPError(_KnowledgeFSServiceFailureHTTPError):
|
||||
error_code = "knowledge_fs_rate_limit_exceeded"
|
||||
description = "Agent Knowledge Base request rate limit exceeded."
|
||||
code = 429
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeFSInvalidCredentialHTTPError",
|
||||
"KnowledgeFSServiceAccessDeniedHTTPError",
|
||||
"KnowledgeFSServiceConflictHTTPError",
|
||||
"KnowledgeFSServiceInvalidRequestHTTPError",
|
||||
"KnowledgeFSServiceOperationUnavailableHTTPError",
|
||||
"KnowledgeFSServiceRateLimitHTTPError",
|
||||
"KnowledgeFSServiceRequestRejectedHTTPError",
|
||||
"KnowledgeFSServiceRequestTooLargeHTTPError",
|
||||
"KnowledgeFSServiceResourceNotFoundHTTPError",
|
||||
"KnowledgeFSServiceTimeoutHTTPError",
|
||||
"KnowledgeFSServiceUpstreamUnavailableHTTPError",
|
||||
]
|
||||
1749
api/controllers/service_api/knowledge_fs/resources.py
Normal file
1749
api/controllers/service_api/knowledge_fs/resources.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import inspect
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum, auto
|
||||
from functools import wraps
|
||||
@@ -12,7 +11,7 @@ from flask_restx import Resource
|
||||
from flask_restx.utils import merge
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
@@ -25,14 +24,14 @@ from controllers.service_api.schema import (
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.login import current_user
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantStatus
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.dataset import Dataset
|
||||
from models.model import ApiToken, App
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
|
||||
from services.feature_service import FeatureService
|
||||
from services.knowledge_rate_limit_service import KnowledgeRateLimitExceededError, enforce_knowledge_rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -268,6 +267,21 @@ def cloud_edition_billing_knowledge_limit_check[**P, R](
|
||||
return interceptor
|
||||
|
||||
|
||||
def check_knowledge_rate_limit(api_token: ApiToken) -> None:
|
||||
"""Enforce the cloud knowledge base request rate limit for a dataset API key's workspace.
|
||||
|
||||
Shared by the ``cloud_edition_billing_rate_limit_check`` decorator and the KnowledgeFS
|
||||
service routes, which authenticate inside their own profile helper rather than through
|
||||
a decorator. No-op unless the workspace has a knowledge rate limit (cloud billing).
|
||||
"""
|
||||
if api_token.tenant_id is None:
|
||||
raise Unauthorized("Tenant id is required for this token.")
|
||||
try:
|
||||
enforce_knowledge_rate_limit(api_token.tenant_id)
|
||||
except KnowledgeRateLimitExceededError as error:
|
||||
raise Forbidden(str(error)) from error
|
||||
|
||||
|
||||
def cloud_edition_billing_rate_limit_check[**P, R](
|
||||
resource: str,
|
||||
api_token_type: str,
|
||||
@@ -278,32 +292,7 @@ def cloud_edition_billing_rate_limit_check[**P, R](
|
||||
api_token = validate_and_get_api_token(api_token_type)
|
||||
|
||||
if resource == "knowledge":
|
||||
tenant_id = api_token.tenant_id
|
||||
if tenant_id is None:
|
||||
raise Unauthorized("Tenant id is required for this token.")
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(tenant_id)
|
||||
if knowledge_rate_limit.enabled:
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{tenant_id}"
|
||||
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
|
||||
request_count = redis_client.zcard(key)
|
||||
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# add ratelimit record
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
session.add(rate_limit_log)
|
||||
raise Forbidden(
|
||||
"Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
check_knowledge_rate_limit(api_token)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
@@ -337,14 +326,16 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
|
||||
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
|
||||
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
|
||||
# N rows -> the key is limited to exactly those datasets
|
||||
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
|
||||
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
|
||||
# per request (not cached) so scope changes take effect immediately.
|
||||
# N rows -> the key is limited to exactly those resources
|
||||
# Only legacy-dataset bindings count here: a key bound solely to KnowledgeFS spaces
|
||||
# is restricted but has no reachable legacy dataset, so it is rejected. A bound key
|
||||
# may only call endpoints carrying one of its dataset ids; endpoints without a
|
||||
# dataset id (e.g. list/create datasets) are rejected. The scope is queried per
|
||||
# request (not cached) so changes take effect immediately.
|
||||
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
|
||||
# typed helper accepts it (runtime proxies every Session method through unchanged).
|
||||
bound_dataset_ids = dataset_api_key_bindings.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
|
||||
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
|
||||
scope = dataset_api_key_bindings.get_key_scope(cast(Session, db.session), api_token.id)
|
||||
if not scope.allows_dataset(str(dataset_id) if dataset_id else None):
|
||||
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
||||
|
||||
if dataset_id:
|
||||
|
||||
@@ -14,7 +14,10 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.base_app_runner import AppRunner
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AgentChatAppGenerateEntity,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
ModelConfigWithCredentialsEntity,
|
||||
UserFrom,
|
||||
)
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler
|
||||
@@ -149,6 +152,15 @@ class BaseAgentRunner(AppRunner):
|
||||
user_id=self.user_id,
|
||||
invoke_from=self.application_generate_entity.invoke_from,
|
||||
)
|
||||
invoke_from = self.application_generate_entity.invoke_from
|
||||
if isinstance(invoke_from, InvokeFrom):
|
||||
tool_entity.runtime.dify_run_context = DifyRunContext(
|
||||
tenant_id=self.tenant_id,
|
||||
app_id=self.app_config.app_id,
|
||||
user_id=self.user_id,
|
||||
user_from=(UserFrom.ACCOUNT if invoke_from.runs_as_account() else UserFrom.END_USER),
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
assert tool_entity.entity.description
|
||||
message_tool = PromptMessageTool(
|
||||
name=tool.tool_name,
|
||||
|
||||
@@ -50,7 +50,9 @@ from core.app.entities.queue_entities import (
|
||||
QueueAgentThoughtEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueRetrieverResourcesEvent,
|
||||
)
|
||||
from core.app.knowledge_citations import knowledge_sources_from_tool_part
|
||||
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
|
||||
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
|
||||
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
|
||||
@@ -280,6 +282,7 @@ class _AgentProcessRecorder:
|
||||
self._tool_by_index: dict[int, str] = {}
|
||||
self._tool_by_call_id: dict[str, str] = {}
|
||||
self._open_tool_by_name: dict[str, set[str]] = {}
|
||||
self._knowledge_receipts: set[str] = set()
|
||||
|
||||
def handle_stream_event(self, event: AgentBackendStreamInternalEvent) -> None:
|
||||
data = event.data
|
||||
@@ -436,6 +439,16 @@ class _AgentProcessRecorder:
|
||||
|
||||
def _record_tool_return_part(self, part: dict[str, Any]) -> None:
|
||||
self._close_thinking_segments()
|
||||
sources = []
|
||||
for source in knowledge_sources_from_tool_part(part):
|
||||
citation = source.knowledge_fs_citation
|
||||
if citation and citation.id not in self._knowledge_receipts and len(self._knowledge_receipts) < 200:
|
||||
self._knowledge_receipts.add(citation.id)
|
||||
sources.append(source)
|
||||
if sources:
|
||||
self._queue_manager.publish(
|
||||
QueueRetrieverResourcesEvent(retriever_resources=sources), PublishFrom.APPLICATION_MANAGER
|
||||
)
|
||||
tool_call_id = _string_or_none(part.get("tool_call_id"))
|
||||
tool_name = _string_or_none(part.get("tool_name"))
|
||||
content = part.get("content")
|
||||
|
||||
@@ -26,6 +26,7 @@ from dify_agent.layers.user_prompt import (
|
||||
DifyUserPromptImageConfig,
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsError
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentAppRunInput,
|
||||
@@ -35,6 +36,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.knowledge_runtime_config import build_knowledge_fs_layer_config
|
||||
from core.app.llm.model_access import resolve_model_context_window, resolve_model_supports_vision
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.file_reference import build_file_reference, is_canonical_file_reference
|
||||
@@ -49,7 +51,6 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
build_ask_human_layer_config,
|
||||
build_config_aware_soul_mention_resolver,
|
||||
build_config_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
load_runtime_agent_skill_configs,
|
||||
)
|
||||
@@ -154,7 +155,10 @@ class AgentAppRuntimeRequestBuilder:
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
try:
|
||||
knowledge_config = build_knowledge_fs_layer_config(agent_soul, run_context=context.dify_context)
|
||||
except KnowledgeFsError as exc:
|
||||
raise AgentAppRuntimeRequestBuildError(exc.code, exc.message) from exc
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
@@ -170,6 +174,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
model_name=agent_soul.model.model,
|
||||
image_detail_config=context.image_detail_config,
|
||||
include_image_references=knowledge_config is not None,
|
||||
)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
@@ -193,6 +198,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value),
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
agent_mode="agent_app",
|
||||
trace_id=context.dify_context.trace_session_id,
|
||||
),
|
||||
backend_binding_ref=context.backend_binding_ref,
|
||||
# ENG-616: expand slash-menu mention tokens to canonical names so
|
||||
@@ -204,7 +210,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
user_files=user_files,
|
||||
tools=tool_layers.plugin_tools,
|
||||
core_tools=tool_layers.core_tools,
|
||||
knowledge=knowledge_config,
|
||||
knowledge_fs=knowledge_config,
|
||||
config_layer_config=config_layer_config,
|
||||
ask_human_config=build_ask_human_layer_config(agent_soul),
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
@@ -232,18 +238,23 @@ class AgentAppRuntimeRequestBuilder:
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
image_detail_config: ImagePromptMessageContent.DETAIL | None,
|
||||
include_image_references: bool,
|
||||
) -> list[DifyUserPromptFileConfig]:
|
||||
supports_vision = any(file.type == FileType.IMAGE for file in files) and resolve_model_supports_vision(
|
||||
run_context=run_context,
|
||||
provider_name=provider_name,
|
||||
model_name=model_name,
|
||||
)
|
||||
return [
|
||||
_build_user_image(file, image_detail_config=image_detail_config)
|
||||
if supports_vision and file.type == FileType.IMAGE
|
||||
else _build_user_download(file)
|
||||
for file in files
|
||||
]
|
||||
user_files: list[DifyUserPromptFileConfig] = []
|
||||
for file in files:
|
||||
if supports_vision and file.type == FileType.IMAGE:
|
||||
user_files.append(_build_user_image(file, image_detail_config=image_detail_config))
|
||||
# KnowledgeFS image search needs the upload reference even when
|
||||
# the model also receives the image as native vision content.
|
||||
if not include_image_references or file.transfer_method != FileTransferMethod.LOCAL_FILE:
|
||||
continue
|
||||
user_files.append(_build_user_download(file))
|
||||
return user_files
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_snapshot_layers(request: CreateRunRequest) -> None:
|
||||
@@ -321,7 +332,9 @@ def _build_user_download(file: File) -> DifyUserPromptDownloadConfig:
|
||||
if file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
if file.remote_url is None:
|
||||
raise AgentAppRuntimeRequestBuildError("agent_user_file_invalid", "Remote user file is missing its URL.")
|
||||
return DifyUserPromptDownloadConfig(type=file_type, transfer_method="remote_url", url=file.remote_url)
|
||||
return DifyUserPromptDownloadConfig(
|
||||
type=file_type, filename=file.filename or None, transfer_method="remote_url", url=file.remote_url
|
||||
)
|
||||
if file.reference is None:
|
||||
raise AgentAppRuntimeRequestBuildError("agent_user_file_invalid", "User file is missing its reference.")
|
||||
reference = file.reference
|
||||
@@ -342,7 +355,9 @@ def _build_user_download(file: File) -> DifyUserPromptDownloadConfig:
|
||||
"agent_user_file_invalid",
|
||||
f"User file transfer method '{file.transfer_method.value}' is unsupported.",
|
||||
)
|
||||
return DifyUserPromptDownloadConfig(type=file_type, transfer_method=transfer_method, reference=reference)
|
||||
return DifyUserPromptDownloadConfig(
|
||||
type=file_type, filename=file.filename or None, transfer_method=transfer_method, reference=reference
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -82,12 +82,16 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
updated_resources.append(
|
||||
{
|
||||
"dataset_id": resource.get("dataset_id"),
|
||||
"knowledge_fs_citation": resource.get("knowledge_fs_citation"),
|
||||
"dataset_name": resource.get("dataset_name"),
|
||||
"document_id": resource.get("document_id"),
|
||||
"document_asset_id": resource.get("document_asset_id"),
|
||||
"segment_id": resource.get("segment_id", ""),
|
||||
"position": resource["position"],
|
||||
"data_source_type": resource.get("data_source_type"),
|
||||
"document_name": resource["document_name"],
|
||||
"document_revision": resource.get("document_revision"),
|
||||
"document_version": resource.get("document_version"),
|
||||
"score": resource["score"],
|
||||
"hit_count": resource.get("hit_count"),
|
||||
"word_count": resource.get("word_count"),
|
||||
|
||||
63
api/core/app/knowledge_citations.py
Normal file
63
api/core/app/knowledge_citations.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Project trusted runtime evidence into Dify's existing citation event contract."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
|
||||
|
||||
|
||||
def knowledge_sources_from_tool_part(part: object) -> list[RetrievalSourceMetadata]:
|
||||
if not isinstance(part, dict) or part.get("part_kind") != "tool-return":
|
||||
return []
|
||||
metadata = part.get("metadata")
|
||||
if not isinstance(metadata, dict) or not isinstance(metadata.get("knowledge_fs_citations"), list):
|
||||
return []
|
||||
content = part.get("content")
|
||||
results = content.get("knowledge_results", []) if isinstance(content, dict) else []
|
||||
text_by_receipt: dict[str, str] = {}
|
||||
for result in results if isinstance(results, list) else []:
|
||||
data = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
items = data.get("items", []) if "items" in data else [data]
|
||||
for item in items if isinstance(items, list) else []:
|
||||
if isinstance(item, dict) and isinstance(item.get("receipt_id"), str) and isinstance(item.get("text"), str):
|
||||
text_by_receipt[item["receipt_id"]] = item["text"][:4096]
|
||||
sources = []
|
||||
seen = set()
|
||||
for raw in metadata["knowledge_fs_citations"][:100]:
|
||||
try:
|
||||
citation = KnowledgeFsCitation.model_validate(raw)
|
||||
except ValidationError:
|
||||
continue
|
||||
if citation.id in seen:
|
||||
continue
|
||||
seen.add(citation.id)
|
||||
sources.append(
|
||||
RetrievalSourceMetadata(
|
||||
dataset_id=citation.control_space_id,
|
||||
dataset_name=citation.space_name,
|
||||
document_id=citation.document_asset_id,
|
||||
document_asset_id=citation.document_asset_id,
|
||||
document_version=citation.document_version,
|
||||
document_name=citation.document_title or citation.document_asset_id,
|
||||
segment_id=citation.node_id,
|
||||
index_node_hash=citation.artifact_hash,
|
||||
data_source_type="knowledge_fs",
|
||||
retriever_from="agent_knowledge_fs_cli",
|
||||
content=text_by_receipt.get(citation.id, ""),
|
||||
page=citation.page_number,
|
||||
knowledge_fs_citation=citation,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def knowledge_sources_from_stream_event(data: object) -> list[RetrievalSourceMetadata]:
|
||||
if not isinstance(data, Mapping) or data.get("event_kind") != "function_tool_result":
|
||||
return []
|
||||
# PydanticAI serializes FunctionToolResultEvent under `part`; retain the
|
||||
# normalized adapter's `result` alias for older recorded events as well.
|
||||
return knowledge_sources_from_tool_part(data.get("part") or data.get("result"))
|
||||
47
api/core/app/knowledge_runtime_config.py
Normal file
47
api/core/app/knowledge_runtime_config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Shared KnowledgeFS layer mapping for Agent App and Workflow AgentV2."""
|
||||
|
||||
from dify_agent.layers.knowledge_fs import DifyKnowledgeFsLayerConfig
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsBinding, KnowledgeFsError
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.llm.model_access import DifyModelFactory
|
||||
from graphon.model_runtime.entities.model_entities import ModelFeature
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def build_knowledge_fs_layer_config(
|
||||
agent_soul: AgentSoulConfig, *, run_context: DifyRunContext
|
||||
) -> DifyKnowledgeFsLayerConfig | None:
|
||||
if agent_soul.knowledge.sets:
|
||||
raise KnowledgeFsError(
|
||||
"KNOWLEDGE_REBIND_REQUIRED",
|
||||
"Replace Classic Knowledge Base bindings with Agent Knowledge Base bindings before running.",
|
||||
)
|
||||
if not agent_soul.knowledge.spaces:
|
||||
return None
|
||||
if any(space.is_missing for space in agent_soul.knowledge.spaces):
|
||||
raise KnowledgeFsError("KNOWLEDGE_REBIND_REQUIRED", "Reselect imported or unavailable Agent Knowledge Bases.")
|
||||
if not dify_config.AGENT_SHELL_ENABLED:
|
||||
raise KnowledgeFsError(
|
||||
"KNOWLEDGE_SHELL_UNAVAILABLE", "Agent Knowledge Base CLI requires the Agent sandbox shell.", 503
|
||||
)
|
||||
if not agent_soul.model:
|
||||
raise KnowledgeFsError(
|
||||
"KNOWLEDGE_MODEL_REQUIRED", "Select an Agent model before using an Agent Knowledge Base."
|
||||
)
|
||||
schema = (
|
||||
DifyModelFactory(run_context=run_context)
|
||||
.init_model_instance(
|
||||
agent_soul.model.model_provider,
|
||||
agent_soul.model.model,
|
||||
)
|
||||
.get_model_schema()
|
||||
)
|
||||
return DifyKnowledgeFsLayerConfig(
|
||||
spaces=[
|
||||
KnowledgeFsBinding.model_validate(space.model_dump(exclude={"is_missing"}))
|
||||
for space in agent_soul.knowledge.spaces
|
||||
],
|
||||
agent_supports_vision=ModelFeature.VISION in (schema.features or []),
|
||||
)
|
||||
@@ -192,6 +192,7 @@ class MessageCycleManager:
|
||||
if self._application_generate_entity.app_config.additional_features.show_retrieve_source:
|
||||
merged_resources = [r for r in self._task_state.metadata.retriever_resources or [] if r]
|
||||
existing_ids = {(r.dataset_id, r.document_id) for r in merged_resources if r.dataset_id and r.document_id}
|
||||
receipt_ids = {r.knowledge_fs_citation.id for r in merged_resources if r.knowledge_fs_citation}
|
||||
|
||||
# Add new unique resources from the event
|
||||
for resource in event.retriever_resources or []:
|
||||
@@ -199,13 +200,20 @@ class MessageCycleManager:
|
||||
continue
|
||||
|
||||
is_duplicate = (
|
||||
resource.dataset_id
|
||||
and resource.document_id
|
||||
and (resource.dataset_id, resource.document_id) in existing_ids
|
||||
resource.knowledge_fs_citation.id in receipt_ids
|
||||
if resource.knowledge_fs_citation
|
||||
else (
|
||||
resource.knowledge_fs_citation is None
|
||||
and resource.dataset_id
|
||||
and resource.document_id
|
||||
and (resource.dataset_id, resource.document_id) in existing_ids
|
||||
)
|
||||
)
|
||||
|
||||
if not is_duplicate:
|
||||
merged_resources.append(resource)
|
||||
if resource.knowledge_fs_citation:
|
||||
receipt_ids.add(resource.knowledge_fs_citation.id)
|
||||
|
||||
for i, resource in enumerate(merged_resources, 1):
|
||||
resource.position = i
|
||||
|
||||
@@ -38,6 +38,11 @@ class DatasourcePluginProviderController(ABC):
|
||||
):
|
||||
raise ToolProviderCredentialValidationError("Invalid credentials")
|
||||
|
||||
def validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None:
|
||||
"""Validate credential shape and value against this installed provider declaration."""
|
||||
self.validate_credentials_format(credentials)
|
||||
self._validate_credentials(user_id, credentials)
|
||||
|
||||
@property
|
||||
def provider_type(self) -> DatasourceProviderType:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import enum
|
||||
import re
|
||||
import string
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, Literal, TypedDict, override
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
|
||||
from yarl import URL
|
||||
|
||||
from configs import dify_config
|
||||
@@ -330,24 +334,139 @@ class WebsiteCrawlMessage(BaseModel):
|
||||
result: WebSiteInfo = WebSiteInfo(status="", web_info_list=[], total=0, completed=0)
|
||||
|
||||
|
||||
class DatasourceMessage(ToolInvokeMessage):
|
||||
pass
|
||||
|
||||
|
||||
#########################
|
||||
# Online drive file
|
||||
#########################
|
||||
|
||||
|
||||
class OnlineDriveChecksum(TypedDict):
|
||||
"""Optional full-file checksum supplied by an online-drive provider."""
|
||||
|
||||
algorithm: Literal["md5", "sha256"]
|
||||
value: str
|
||||
|
||||
|
||||
class OnlineDriveRemoteMetadata(TypedDict, total=False):
|
||||
"""Bounded wire metadata used as a hint for online-drive change detection."""
|
||||
|
||||
version_id: str
|
||||
etag: str
|
||||
checksum: OnlineDriveChecksum
|
||||
modified_time: str
|
||||
|
||||
|
||||
_ONLINE_DRIVE_TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$")
|
||||
|
||||
|
||||
def _online_drive_token(value: Any) -> str | None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value.strip()
|
||||
or len(value) > 1024
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
||||
):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _online_drive_timestamp(value: Any) -> str | None:
|
||||
if not isinstance(value, str) or len(value) > 64 or not _ONLINE_DRIVE_TIMESTAMP_PATTERN.fullmatch(value):
|
||||
return None
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_online_drive_remote_metadata(value: Any) -> OnlineDriveRemoteMetadata | None:
|
||||
"""Keep only the optional, non-secret fields understood by KnowledgeFS."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
result: OnlineDriveRemoteMetadata = {}
|
||||
version_id = _online_drive_token(value.get("version_id"))
|
||||
if version_id and version_id != "null":
|
||||
result["version_id"] = version_id
|
||||
etag = _online_drive_token(value.get("etag"))
|
||||
if etag and not etag.lower().startswith("w/"):
|
||||
result["etag"] = etag
|
||||
modified_time = _online_drive_timestamp(value.get("modified_time"))
|
||||
if modified_time:
|
||||
result["modified_time"] = modified_time
|
||||
|
||||
checksum = value.get("checksum")
|
||||
if isinstance(checksum, dict):
|
||||
algorithm, digest = checksum.get("algorithm"), checksum.get("value")
|
||||
normalized_algorithm: Literal["md5", "sha256"] | None = None
|
||||
if algorithm == "md5":
|
||||
normalized_algorithm = "md5"
|
||||
elif algorithm == "sha256":
|
||||
normalized_algorithm = "sha256"
|
||||
digest_length = 32 if normalized_algorithm == "md5" else 64 if normalized_algorithm == "sha256" else None
|
||||
if (
|
||||
normalized_algorithm is not None
|
||||
and digest_length is not None
|
||||
and isinstance(digest, str)
|
||||
and len(digest) == digest_length
|
||||
and all(character in string.hexdigits for character in digest)
|
||||
):
|
||||
result["checksum"] = {"algorithm": normalized_algorithm, "value": digest.lower()}
|
||||
return result or None
|
||||
|
||||
|
||||
class DatasourceMessage(ToolInvokeMessage):
|
||||
pass
|
||||
|
||||
|
||||
class OnlineDriveDownloadMessage(DatasourceMessage):
|
||||
"""Datasource invoke message with a bounded online-drive download receipt."""
|
||||
|
||||
@field_validator("meta", mode="before")
|
||||
@classmethod
|
||||
def bounded_remote_metadata(cls, value: Any) -> Any:
|
||||
if not isinstance(value, dict) or "remote_metadata" not in value:
|
||||
return value
|
||||
result = dict(value)
|
||||
remote_metadata = _bounded_online_drive_remote_metadata(value.get("remote_metadata"))
|
||||
if remote_metadata:
|
||||
result["remote_metadata"] = remote_metadata
|
||||
else:
|
||||
result.pop("remote_metadata", None)
|
||||
return result
|
||||
|
||||
@field_serializer("message")
|
||||
@override
|
||||
def serialize_message(self, v: Any) -> Any:
|
||||
"""Keep online-drive blob chunks JSON-safe across the Dify inner API bridge."""
|
||||
if isinstance(v, self.BlobChunkMessage):
|
||||
return {
|
||||
"id": v.id,
|
||||
"sequence": v.sequence,
|
||||
"total_length": v.total_length,
|
||||
"blob": base64.b64encode(v.blob).decode("utf-8"),
|
||||
"end": v.end,
|
||||
}
|
||||
return super().serialize_message(v)
|
||||
|
||||
|
||||
class OnlineDriveFile(BaseModel):
|
||||
"""
|
||||
Online drive file
|
||||
"""
|
||||
"""Online drive file."""
|
||||
|
||||
id: str = Field(..., description="The file ID")
|
||||
name: str = Field(..., description="The file name")
|
||||
size: int = Field(..., description="The file size")
|
||||
type: str = Field(..., description="The file type: folder or file")
|
||||
remote_metadata: OnlineDriveRemoteMetadata | None = Field(
|
||||
None,
|
||||
description="Optional provider version_id, etag, checksum and modified_time; not credentials",
|
||||
)
|
||||
|
||||
@field_validator("remote_metadata", mode="before")
|
||||
@classmethod
|
||||
def bounded_remote_metadata(cls, value: Any) -> OnlineDriveRemoteMetadata | None:
|
||||
"""Keep the optional wire extension bounded without rejecting legacy file listings."""
|
||||
return _bounded_online_drive_remote_metadata(value)
|
||||
|
||||
|
||||
class OnlineDriveFileBucket(BaseModel):
|
||||
|
||||
1
api/core/knowledge_fs/__init__.py
Normal file
1
api/core/knowledge_fs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""KnowledgeFS contracts at the workflow/application boundary."""
|
||||
90
api/core/knowledge_fs/errors.py
Normal file
90
api/core/knowledge_fs/errors.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Errors and image-reference values shared across KnowledgeFS capability boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from core.knowledge_fs.retrieval_contracts import KnowledgeFSPublicFailureResponse
|
||||
|
||||
QUERY_IMAGE_MAX_COUNT = 4
|
||||
QUERY_IMAGE_MAX_TOTAL_BYTES = 32 * 1024 * 1024
|
||||
|
||||
|
||||
class KnowledgeFSAppAdmissionError(RuntimeError):
|
||||
"""The app is not explicitly bound to an enabled KnowledgeFS channel."""
|
||||
|
||||
|
||||
class KnowledgeFSAppBindingNotEnabledError(KnowledgeFSAppAdmissionError):
|
||||
"""The requested app binding is missing, revoked, or outside the caller scope."""
|
||||
|
||||
|
||||
class KnowledgeFSAppChannelDisabledError(KnowledgeFSAppAdmissionError):
|
||||
"""The requested app caller channel is disabled for the control-space."""
|
||||
|
||||
|
||||
class KnowledgeFSAppSpaceUnavailableError(KnowledgeFSAppAdmissionError):
|
||||
"""The bound control-space is not active and provisioned for product traffic."""
|
||||
|
||||
|
||||
class KnowledgeFSAppAuthorizationNotReadyError(KnowledgeFSAppAdmissionError):
|
||||
"""Required local authorization policy or revision state is unavailable."""
|
||||
|
||||
|
||||
class KnowledgeFSAppBindingManagementError(RuntimeError):
|
||||
"""An app binding cannot be created or changed safely."""
|
||||
|
||||
|
||||
class KnowledgeFSProductRemoteError(RuntimeError):
|
||||
"""KnowledgeFS could not provide an authoritative product response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
failure: KnowledgeFSPublicFailureResponse | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.failure = failure
|
||||
|
||||
|
||||
class KnowledgeFSProductResourceNotFoundError(KnowledgeFSProductRemoteError):
|
||||
"""KnowledgeFS authoritatively reported that an authorized child resource is absent."""
|
||||
|
||||
|
||||
class KnowledgeFSOperationUnavailableError(RuntimeError):
|
||||
"""The Dify/KFS/Capability operation manifests are not yet aligned."""
|
||||
|
||||
|
||||
class KnowledgeFSProductRequestRejectedError(RuntimeError):
|
||||
"""A bounded product request was rejected locally or by authoritative KFS validation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: Literal[400, 403, 409, 413, 422, 429, 503, 504],
|
||||
violations: list[dict[str, str | int | float | list[str]]] | None = None,
|
||||
failure: KnowledgeFSPublicFailureResponse | None = None,
|
||||
) -> None:
|
||||
super().__init__(f"KnowledgeFS rejected the product request with HTTP {status_code}")
|
||||
self.status_code = status_code
|
||||
self.violations = violations
|
||||
self.failure = failure
|
||||
|
||||
|
||||
class KnowledgeFSQueryImageError(ValueError):
|
||||
"""A safe validation error raised before query model or retrieval work starts."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeFSWorkflowQueryImageReference:
|
||||
"""One workflow-authorized Dify file reference safe to forward through KnowledgeFS."""
|
||||
|
||||
upload_file_id: str
|
||||
access_grant: str = field(repr=False)
|
||||
byte_size: int
|
||||
mime_type: str
|
||||
24
api/core/knowledge_fs/resource.py
Normal file
24
api/core/knowledge_fs/resource.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Shared KnowledgeFS contracts consumed by workflow nodes and application services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class KnowledgeResourceRef(BaseModel):
|
||||
"""A typed app configuration reference to one Dify-owned KnowledgeFS control-space."""
|
||||
|
||||
kind: Literal["knowledge_fs"]
|
||||
control_space_id: str = Field(min_length=1, max_length=1_000)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@field_validator("control_space_id")
|
||||
@classmethod
|
||||
def normalize_control_space_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("KnowledgeFS control-space reference is required")
|
||||
return normalized
|
||||
621
api/core/knowledge_fs/retrieval_contracts.py
Normal file
621
api/core/knowledge_fs/retrieval_contracts.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""Shared KnowledgeFS contracts consumed by workflow nodes and application services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, ClassVar, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from models.knowledge_fs import (
|
||||
KnowledgeFSAppSpaceJoinType,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSAppBindingPayload(BaseModel):
|
||||
app_id: str = Field(min_length=1)
|
||||
caller_kind: KnowledgeFSAppSpaceJoinType
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class KnowledgeFSMetadataFieldResponse(ResponseModel):
|
||||
count: int = Field(ge=0)
|
||||
created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt"))
|
||||
id: str
|
||||
name: str
|
||||
row_version: int = Field(ge=0, validation_alias=AliasChoices("row_version", "rowVersion"))
|
||||
type: Literal["string", "number", "time"]
|
||||
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
|
||||
|
||||
|
||||
class KnowledgeFSMetadataFieldListResponse(ResponseModel):
|
||||
data: list[KnowledgeFSMetadataFieldResponse] = Field(validation_alias=AliasChoices("data", "items"))
|
||||
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
|
||||
|
||||
|
||||
KnowledgeFSPublicErrorCode = Literal[
|
||||
"KNOWLEDGE_SPACE_SETTINGS_COMPILATION_IN_PROGRESS",
|
||||
"KNOWLEDGE_SPACE_SETTINGS_REVISION_CONFLICT",
|
||||
"KNOWLEDGE_SPACE_SETTINGS_MIGRATION_REQUIRED",
|
||||
"DOCUMENT_COMPILATION_PROFILE_CHANGED",
|
||||
"DOCUMENT_COMPILATION_FAILED",
|
||||
"DOCUMENT_COMPILATION_RETRYABLE",
|
||||
"DOCUMENT_COMPILATION_LEASE_LOST",
|
||||
"DOCUMENT_DISABLED",
|
||||
"DOCUMENT_PARSER_INPUT_INVALID",
|
||||
"DOCUMENT_PARSER_NOT_CONFIGURED",
|
||||
"DOCUMENT_PARSER_RATE_LIMITED",
|
||||
"DOCUMENT_PARSER_RESPONSE_INVALID",
|
||||
"DOCUMENT_PARSER_TIMEOUT",
|
||||
"DOCUMENT_PARSER_UNAVAILABLE",
|
||||
"DOCUMENT_PARSER_UNSUPPORTED_TYPE",
|
||||
"DOCUMENT_PDF_RENDER_FAILED",
|
||||
"EMBEDDING_DIMENSION_INVALID",
|
||||
"EMBEDDING_DIMENSION_UNSUPPORTED",
|
||||
"EXECUTION_ATTEMPTS_EXHAUSTED",
|
||||
"KNOWLEDGE_FS_ACCESS_DENIED",
|
||||
"KNOWLEDGE_FS_CONFLICT",
|
||||
"LOGICAL_DOCUMENT_CAS_CONFLICT",
|
||||
"DURABLE_DELETION_IDEMPOTENCY_CONFLICT",
|
||||
"SOURCE_VERSION_CONFLICT",
|
||||
"KNOWLEDGE_FS_INTERNAL_ERROR",
|
||||
"KNOWLEDGE_FS_INVALID_REQUEST",
|
||||
"KNOWLEDGE_FS_NOT_FOUND",
|
||||
"KNOWLEDGE_FS_RATE_LIMITED",
|
||||
"KNOWLEDGE_FS_TIMEOUT",
|
||||
"KNOWLEDGE_FS_UNAVAILABLE",
|
||||
"KNOWLEDGE_SPACE_MANIFEST_NOT_FOUND",
|
||||
"KNOWLEDGE_SPACE_MODEL_CONFIGURATION_REQUIRED",
|
||||
"MODEL_CAPABILITY_MISMATCH",
|
||||
"MODEL_CONFIGURATION_STALE",
|
||||
"MODEL_CREDENTIAL_INVALID",
|
||||
"MODEL_CREDENTIAL_VALIDATION_UNAVAILABLE",
|
||||
"MODEL_IDENTITY_MISMATCH",
|
||||
"MODEL_PREFLIGHT_CANCELED",
|
||||
"MODEL_PREFLIGHT_FAILED",
|
||||
"MODEL_PREFLIGHT_TIMEOUT",
|
||||
"MODEL_PREFLIGHT_UNAVAILABLE",
|
||||
"MODEL_PROFILE_ACTIVATION_INCOMPLETE",
|
||||
"MODEL_PROFILE_ACTIVATION_PERMISSION_REQUIRED",
|
||||
"MODEL_RUNTIME_FAILED",
|
||||
"MODEL_RUNTIME_CONTEXT_LIMIT",
|
||||
"MODEL_RUNTIME_OUTPUT_LIMIT",
|
||||
"MODEL_RUNTIME_RESPONSE_INVALID",
|
||||
"MODEL_RUNTIME_TIMEOUT",
|
||||
"MODEL_RUNTIME_UNAVAILABLE",
|
||||
"MODEL_SELECTION_NOT_FOUND",
|
||||
"RESEARCH_TASK_CAPABILITY_REVOKED",
|
||||
"RESEARCH_TASK_DISPATCH_DEAD",
|
||||
"RESEARCH_TASK_EXECUTION_ATTEMPTS_EXHAUSTED",
|
||||
"RESEARCH_TASK_FAILED",
|
||||
"RESEARCH_TASK_PERMISSION_SNAPSHOT_INVALID",
|
||||
"RESEARCH_TASK_RUNTIME_SNAPSHOT_INVALID",
|
||||
"RETRIEVAL_DELETION_IN_PROGRESS",
|
||||
"RETRIEVAL_EXECUTION_LEASE_LOST",
|
||||
"SOURCE_BULK_ACTION_FAILED",
|
||||
"SOURCE_CONNECTION_UNAVAILABLE",
|
||||
"SOURCE_CRAWL_PAGE_NOT_FOUND",
|
||||
"SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
|
||||
"SOURCE_CRAWL_RESULT_LIMIT_EXCEEDED",
|
||||
"SOURCE_CREDENTIAL_CONFIG_INVALID",
|
||||
"SOURCE_CREDENTIAL_MUTATION_FAILED",
|
||||
"SOURCE_CREDENTIAL_TEST_FAILED",
|
||||
"SOURCE_CREDENTIAL_UNAVAILABLE",
|
||||
"SOURCE_DOCUMENT_COMPILATION_FAILED",
|
||||
"SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
|
||||
"SOURCE_DOCUMENT_REPLACEMENT_SAGA_REQUIRED",
|
||||
"SOURCE_IMPORT_PARTIAL_FAILURE",
|
||||
"SOURCE_ONLINE_DOCUMENT_CONFIG_INVALID",
|
||||
"SOURCE_ONLINE_DOCUMENT_IMPORT_FAILED",
|
||||
"SOURCE_ONLINE_DOCUMENT_PAGE_FETCH_FAILED",
|
||||
"SOURCE_ONLINE_DOCUMENT_REQUEST_FAILED",
|
||||
"SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
||||
"SOURCE_ONLINE_DRIVE_CONFIG_INVALID",
|
||||
"SOURCE_ONLINE_DRIVE_FILE_DOWNLOAD_FAILED",
|
||||
"SOURCE_ONLINE_DRIVE_IMPORT_FAILED",
|
||||
"SOURCE_ONLINE_DRIVE_REQUEST_FAILED",
|
||||
"SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
||||
"SOURCE_OPERATION_FAILED",
|
||||
"SOURCE_SECRET_INTEGRITY_FAILED",
|
||||
"SOURCE_SECRET_REF_CONFLICT",
|
||||
"SOURCE_SYNC_FAILED",
|
||||
"SOURCE_PROVIDER_REJECTED",
|
||||
"SOURCE_PROVIDER_TIMEOUT",
|
||||
"SOURCE_PROVIDER_UNAVAILABLE",
|
||||
"SOURCE_SYNC_SELECTION_MISMATCH",
|
||||
"SOURCE_WEBSITE_CRAWL_CONFIG_INVALID",
|
||||
"SOURCE_WEBSITE_CRAWL_FAILED",
|
||||
"SOURCE_WORKFLOW_FAILED",
|
||||
"SOURCE_WORKFLOW_CONTENT_MISSING",
|
||||
"SOURCE_WORKFLOW_CONTENT_TOO_LARGE",
|
||||
"SOURCE_WORKFLOW_EXTERNAL_TIMEOUT",
|
||||
"UPLOAD_INITIALIZATION_FAILED",
|
||||
"UPLOAD_INTEGRITY_MISMATCH",
|
||||
]
|
||||
|
||||
|
||||
class KnowledgeFSPublicFailureResponse(ResponseModel):
|
||||
_SAFE_MESSAGE_BY_CATEGORY: ClassVar[dict[str, str]] = {
|
||||
"authorization": "You do not have permission to perform this Agent Knowledge Base operation.",
|
||||
"canceled": "The Agent Knowledge Base operation was canceled.",
|
||||
"configuration": "The Agent Knowledge Base operation requires a configuration change before it can continue.",
|
||||
"conflict": "The Agent Knowledge Base operation conflicts with the current resource state.",
|
||||
"dependency": "A service required by Agent Knowledge Base is temporarily unavailable.",
|
||||
"internal": (
|
||||
"Agent Knowledge Base could not complete the operation. Try again, or contact an administrator "
|
||||
"with the error reference."
|
||||
),
|
||||
"not_found": "The requested Agent Knowledge Base resource was not found.",
|
||||
"rate_limit": "Too many Agent Knowledge Base operations were requested. Try again later.",
|
||||
"timeout": "The Agent Knowledge Base operation timed out. Try again later.",
|
||||
"validation": "The Agent Knowledge Base request is invalid.",
|
||||
}
|
||||
_SAFE_MESSAGE_BY_CODE: ClassVar[dict[str, str]] = {
|
||||
"KNOWLEDGE_SPACE_SETTINGS_COMPILATION_IN_PROGRESS": (
|
||||
"Documents are being processed. Wait for processing to finish or cancel the tasks before changing models."
|
||||
),
|
||||
"KNOWLEDGE_SPACE_SETTINGS_REVISION_CONFLICT": (
|
||||
"Knowledge space settings have changed. "
|
||||
"Reload the latest settings and review your changes before saving again."
|
||||
),
|
||||
"KNOWLEDGE_SPACE_SETTINGS_MIGRATION_REQUIRED": (
|
||||
"This model change requires an index rebuild. Refresh settings and use the model migration workflow."
|
||||
),
|
||||
"DOCUMENT_COMPILATION_PROFILE_CHANGED": (
|
||||
"The model configuration changed or is unavailable. "
|
||||
"Check the knowledge space models before retrying document processing."
|
||||
),
|
||||
"RETRIEVAL_DELETION_IN_PROGRESS": "This Agent Knowledge Base is being deleted and cannot be searched.",
|
||||
"RETRIEVAL_EXECUTION_LEASE_LOST": (
|
||||
"The retrieval execution expired before it could finish. Run the query again."
|
||||
),
|
||||
"SOURCE_SYNC_SELECTION_MISMATCH": ("The source document inventory does not match the configured selection."),
|
||||
}
|
||||
_SAFE_PARAMETER_KEYS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"attempt",
|
||||
"currentRowVersion",
|
||||
"expectedRowVersion",
|
||||
"documentCount",
|
||||
"fileSizeBytes",
|
||||
"limit",
|
||||
"maxFileSizeBytes",
|
||||
"maxItems",
|
||||
"modelType",
|
||||
"providerKind",
|
||||
"retryAfterSeconds",
|
||||
"status",
|
||||
}
|
||||
)
|
||||
action: (
|
||||
Literal[
|
||||
"configure_model",
|
||||
"configure_parser",
|
||||
"configure_source",
|
||||
"contact_admin",
|
||||
"reupload",
|
||||
"retry",
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
category: Literal[
|
||||
"authorization",
|
||||
"canceled",
|
||||
"configuration",
|
||||
"conflict",
|
||||
"dependency",
|
||||
"internal",
|
||||
"not_found",
|
||||
"rate_limit",
|
||||
"timeout",
|
||||
"validation",
|
||||
]
|
||||
code: KnowledgeFSPublicErrorCode
|
||||
message: str = Field(min_length=1, max_length=1_024)
|
||||
parameters: dict[str, str | int | float | bool] | None = Field(default=None, max_length=8)
|
||||
retry_policy: Literal["automatic", "manual", "after_configuration", "never"] = Field(
|
||||
validation_alias=AliasChoices("retry_policy", "retryPolicy"),
|
||||
serialization_alias="retryPolicy",
|
||||
)
|
||||
stage: str | None = Field(default=None, min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
|
||||
trace_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9._:-]{1,128}$",
|
||||
validation_alias=AliasChoices("trace_id", "traceId"),
|
||||
serialization_alias="traceId",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("parameters")
|
||||
@classmethod
|
||||
def validate_public_parameters(
|
||||
cls, parameters: dict[str, str | int | float | bool] | None
|
||||
) -> dict[str, str | int | float | bool] | None:
|
||||
if parameters is None:
|
||||
return None
|
||||
for key, value in parameters.items():
|
||||
if key not in cls._SAFE_PARAMETER_KEYS or re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", key) is None:
|
||||
raise ValueError("KnowledgeFS public failure parameter key is invalid")
|
||||
if isinstance(value, str) and len(value) > 256:
|
||||
raise ValueError("KnowledgeFS public failure parameter value is too long")
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError("KnowledgeFS public failure parameter must be finite")
|
||||
return parameters
|
||||
|
||||
@model_validator(mode="after")
|
||||
def replace_untrusted_message_with_bff_fallback(self) -> KnowledgeFSPublicFailureResponse:
|
||||
self.message = self._SAFE_MESSAGE_BY_CODE.get(self.code, self._SAFE_MESSAGE_BY_CATEGORY[self.category])
|
||||
return self
|
||||
|
||||
|
||||
class KnowledgeFSQueryImageReference(BaseModel):
|
||||
upload_file_id: str = Field(min_length=1, alias="uploadFileId")
|
||||
|
||||
model_config = ConfigDict(extra="forbid", serialize_by_alias=True, validate_by_alias=True, validate_by_name=True)
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("uploadFileId must be a UUID") from exc
|
||||
return value
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalQueryImageReference(KnowledgeFSQueryImageReference):
|
||||
"""Transient workflow file authorization forwarded only by retrieval-test requests."""
|
||||
|
||||
access_grant: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=2_048,
|
||||
alias="accessGrant",
|
||||
exclude_if=lambda value: value is None,
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
KnowledgeFSRetrievalFilterValue = Annotated[str, Field(min_length=1, max_length=512)]
|
||||
|
||||
|
||||
KnowledgeFSRetrievalCustomMetadataOperator = Literal[
|
||||
"contains",
|
||||
"not contains",
|
||||
"start with",
|
||||
"end with",
|
||||
"is",
|
||||
"is not",
|
||||
"empty",
|
||||
"not empty",
|
||||
"in",
|
||||
"not in",
|
||||
"=",
|
||||
"≠",
|
||||
">",
|
||||
"<",
|
||||
"≥",
|
||||
"≤",
|
||||
"before",
|
||||
"after",
|
||||
]
|
||||
|
||||
|
||||
KnowledgeFSRetrievalReservedMetadataNames = frozenset(
|
||||
{"displayName", "provenance", "retrievalCount", "sourceName", "system"}
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalCustomMetadataCondition(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
field_type: Literal["string", "number", "time"] = Field(
|
||||
validation_alias=AliasChoices("field_type", "fieldType"),
|
||||
serialization_alias="fieldType",
|
||||
)
|
||||
comparison_operator: KnowledgeFSRetrievalCustomMetadataOperator = Field(
|
||||
validation_alias=AliasChoices("comparison_operator", "comparisonOperator"),
|
||||
serialization_alias="comparisonOperator",
|
||||
)
|
||||
value: str | int | float | None = Field(default=None, exclude_if=lambda value: value is None)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str) -> str:
|
||||
if value in KnowledgeFSRetrievalReservedMetadataNames:
|
||||
raise ValueError("Retrieval custom metadata field name must be non-reserved")
|
||||
return value
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_value(cls, value: str | int | float | None) -> str | int | float | None:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Retrieval custom metadata values must not be booleans")
|
||||
if isinstance(value, str) and len(value) > 512:
|
||||
raise ValueError("Retrieval custom metadata string values must contain at most 512 characters")
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError("Retrieval custom metadata numeric values must be finite")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_operator_and_value(self) -> KnowledgeFSRetrievalCustomMetadataCondition:
|
||||
operators = {
|
||||
"number": {"=", "≠", ">", "<", "≥", "≤", "empty", "not empty"},
|
||||
"string": {
|
||||
"contains",
|
||||
"not contains",
|
||||
"start with",
|
||||
"end with",
|
||||
"is",
|
||||
"is not",
|
||||
"empty",
|
||||
"not empty",
|
||||
"in",
|
||||
"not in",
|
||||
},
|
||||
"time": {"is", "before", "after", "empty", "not empty"},
|
||||
}
|
||||
if self.comparison_operator not in operators[self.field_type]:
|
||||
raise ValueError(f"Retrieval custom metadata operator is invalid for {self.field_type}")
|
||||
if self.comparison_operator in {"empty", "not empty"}:
|
||||
return self
|
||||
if self.value is None:
|
||||
raise ValueError("Retrieval custom metadata condition value is required")
|
||||
if self.field_type == "number" and (isinstance(self.value, bool) or not isinstance(self.value, int | float)):
|
||||
raise ValueError("Retrieval custom metadata number conditions require a numeric value")
|
||||
if self.field_type == "string" and not isinstance(self.value, str):
|
||||
raise ValueError("Retrieval custom metadata string conditions require a string value")
|
||||
if self.field_type == "time":
|
||||
if isinstance(self.value, bool) or not isinstance(self.value, str | int | float):
|
||||
raise ValueError("Retrieval custom metadata time conditions require a timestamp value")
|
||||
if isinstance(self.value, str):
|
||||
try:
|
||||
datetime.fromisoformat(self.value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Retrieval custom metadata time conditions require a valid timestamp") from exc
|
||||
else:
|
||||
try:
|
||||
datetime.fromtimestamp(self.value, tz=UTC)
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise ValueError("Retrieval custom metadata time conditions require a valid timestamp") from exc
|
||||
return self
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalCustomMetadataFilter(BaseModel):
|
||||
logical_operator: Literal["and", "or"] = Field(
|
||||
default="and",
|
||||
validation_alias=AliasChoices("logical_operator", "logicalOperator"),
|
||||
serialization_alias="logicalOperator",
|
||||
)
|
||||
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = Field(default_factory=list, max_length=50)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalMetadataFilters(BaseModel):
|
||||
created_after: str | None = Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
validation_alias=AliasChoices("created_after", "createdAfter"),
|
||||
serialization_alias="createdAfter",
|
||||
)
|
||||
created_before: str | None = Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
validation_alias=AliasChoices("created_before", "createdBefore"),
|
||||
serialization_alias="createdBefore",
|
||||
)
|
||||
custom_metadata: KnowledgeFSRetrievalCustomMetadataFilter | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("custom_metadata", "customMetadata"),
|
||||
serialization_alias="customMetadata",
|
||||
)
|
||||
document_types: list[KnowledgeFSRetrievalFilterValue] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
validation_alias=AliasChoices("document_types", "documentTypes"),
|
||||
serialization_alias="documentTypes",
|
||||
)
|
||||
entities: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
|
||||
freshness_statuses: list[KnowledgeFSRetrievalFilterValue] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
validation_alias=AliasChoices("freshness_statuses", "freshnessStatuses"),
|
||||
serialization_alias="freshnessStatuses",
|
||||
)
|
||||
languages: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
|
||||
node_kinds: list[Literal["chunk", "section", "table", "image", "summary"]] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
validation_alias=AliasChoices("node_kinds", "nodeKinds"),
|
||||
serialization_alias="nodeKinds",
|
||||
)
|
||||
source_ids: list[KnowledgeFSRetrievalFilterValue] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
validation_alias=AliasChoices("source_ids", "sourceIds"),
|
||||
serialization_alias="sourceIds",
|
||||
)
|
||||
tags: list[KnowledgeFSRetrievalFilterValue] | None = Field(default=None, max_length=100)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
@field_validator("created_after", "created_before")
|
||||
@classmethod
|
||||
def validate_date_filter(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
try:
|
||||
datetime.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Retrieval date filters must be valid ISO date strings") from exc
|
||||
return normalized
|
||||
|
||||
@field_validator(
|
||||
"document_types",
|
||||
"entities",
|
||||
"freshness_statuses",
|
||||
"languages",
|
||||
"source_ids",
|
||||
"tags",
|
||||
)
|
||||
@classmethod
|
||||
def normalize_string_filters(cls, values: list[str] | None) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
normalized = [value.strip() for value in values]
|
||||
if any(not value for value in normalized):
|
||||
raise ValueError("Retrieval filter values must be non-empty")
|
||||
return list(dict.fromkeys(normalized))
|
||||
|
||||
@field_validator("node_kinds")
|
||||
@classmethod
|
||||
def deduplicate_node_kinds(
|
||||
cls,
|
||||
values: list[Literal["chunk", "section", "table", "image", "summary"]] | None,
|
||||
) -> list[Literal["chunk", "section", "table", "image", "summary"]] | None:
|
||||
return None if values is None else list(dict.fromkeys(values))
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalTestPayload(BaseModel):
|
||||
query: str = Field(default="", max_length=16_000)
|
||||
query_id: UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("query_id", "queryId"),
|
||||
serialization_alias="queryId",
|
||||
)
|
||||
query_images: list[KnowledgeFSRetrievalQueryImageReference] = Field(
|
||||
default_factory=list,
|
||||
max_length=4,
|
||||
alias="queryImages",
|
||||
exclude_if=lambda value: not value,
|
||||
)
|
||||
mode: Literal["deep", "fast", "research"] | None = None
|
||||
include_text: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("include_text", "includeText"),
|
||||
serialization_alias="includeText",
|
||||
)
|
||||
filters: KnowledgeFSRetrievalMetadataFilters | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def normalize_query(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_modality(self) -> KnowledgeFSRetrievalTestPayload:
|
||||
if not self.query and not self.query_images:
|
||||
raise ValueError("At least one of query or queryImages is required")
|
||||
if len({image.upload_file_id for image in self.query_images}) != len(self.query_images):
|
||||
raise ValueError("queryImages must not contain duplicate uploadFileId values")
|
||||
return self
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalCitationResponse(ResponseModel):
|
||||
artifact_hash: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("artifact_hash", "artifactHash"),
|
||||
)
|
||||
document_asset_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
validation_alias=AliasChoices("document_asset_id", "documentAssetId"),
|
||||
)
|
||||
document_title: str | None = Field(
|
||||
default=None,
|
||||
max_length=512,
|
||||
validation_alias=AliasChoices("document_title", "documentTitle"),
|
||||
)
|
||||
document_version: int = Field(
|
||||
gt=0,
|
||||
validation_alias=AliasChoices("document_version", "documentVersion"),
|
||||
)
|
||||
knowledge_space_name: str | None = Field(
|
||||
default=None,
|
||||
max_length=160,
|
||||
validation_alias=AliasChoices("knowledge_space_name", "knowledgeSpaceName"),
|
||||
)
|
||||
logical_document_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
validation_alias=AliasChoices("logical_document_id", "logicalDocumentId"),
|
||||
)
|
||||
logical_document_revision: int | None = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
validation_alias=AliasChoices("logical_document_revision", "logicalDocumentRevision"),
|
||||
)
|
||||
section_path: list[str] = Field(
|
||||
max_length=64,
|
||||
validation_alias=AliasChoices("section_path", "sectionPath"),
|
||||
)
|
||||
page_number: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("page_number", "pageNumber"),
|
||||
)
|
||||
start_offset: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("start_offset", "startOffset"),
|
||||
)
|
||||
end_offset: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("end_offset", "endOffset"),
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalTestItemResponse(ResponseModel):
|
||||
citation: KnowledgeFSRetrievalCitationResponse
|
||||
node_id: str = Field(min_length=1, max_length=512, validation_alias=AliasChoices("node_id", "nodeId"))
|
||||
projection_ids: list[str] = Field(
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("projection_ids", "projectionIds"),
|
||||
)
|
||||
# The final score is profile-defined (for example rerank relevance or RRF fusion), so the
|
||||
# KnowledgeFS contract intentionally guarantees only a finite number rather than [0, 1].
|
||||
score: float = Field(allow_inf_nan=False)
|
||||
sources: list[Literal["dense", "fts", "pageindex", "visual"]] = Field(max_length=4)
|
||||
text: str | None = Field(default=None, max_length=8_192)
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalTestMetricsResponse(ResponseModel):
|
||||
total_ms: float = Field(ge=0, validation_alias=AliasChoices("total_ms", "totalMs"))
|
||||
degradation_flags: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=32,
|
||||
validation_alias=AliasChoices("degradation_flags", "degradationFlags"),
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalTestResponse(ResponseModel):
|
||||
# AnswerTrace recorded for this run in the space's retrieval history; a failed-retrieval capture
|
||||
# attaches to it so one record stays per retrieval.
|
||||
answer_trace_id: str | None = Field(default=None, validation_alias=AliasChoices("answer_trace_id", "answerTraceId"))
|
||||
items: list[KnowledgeFSRetrievalTestItemResponse] = Field(max_length=100)
|
||||
metrics: KnowledgeFSRetrievalTestMetricsResponse
|
||||
mode: Literal["deep", "fast", "research"]
|
||||
trace_id: str = Field(min_length=1, max_length=512, validation_alias=AliasChoices("trace_id", "traceId"))
|
||||
110
api/core/plugin/backwards_invocation/datasource.py
Normal file
110
api/core/plugin/backwards_invocation/datasource.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Datasource backward invocation through Dify's tenant-bound runtime.
|
||||
|
||||
This module is the internal service boundary used by trusted callers such as
|
||||
KnowledgeFS. It resolves installed provider declarations and Dify-owned
|
||||
credential references before reaching ``PluginDatasourceManager``; callers
|
||||
must never provide raw datasource credentials or a plugin-daemon API key.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
from core.datasource.entities.datasource_entities import (
|
||||
OnlineDriveBrowseFilesRequest,
|
||||
OnlineDriveDownloadFileRequest,
|
||||
)
|
||||
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
|
||||
from core.datasource.online_drive.online_drive_plugin import OnlineDriveDatasourcePlugin
|
||||
from core.datasource.website_crawl.website_crawl_plugin import WebsiteCrawlDatasourcePlugin
|
||||
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
|
||||
from core.plugin.entities.request import RequestInvokeDatasource
|
||||
from models.account import Tenant
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
|
||||
|
||||
class PluginDatasourceBackwardsInvocation(BaseBackwardsInvocation):
|
||||
"""Resolve and invoke a datasource without exposing credential material to the caller."""
|
||||
|
||||
@classmethod
|
||||
def invoke(
|
||||
cls,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant: Tenant,
|
||||
payload: RequestInvokeDatasource,
|
||||
) -> Generator[BaseModel | dict[str, Any], None, None]:
|
||||
"""Yield datasource messages for one validated inner-runtime request."""
|
||||
provider_id = DatasourceProviderID(payload.provider)
|
||||
canonical_provider_id = str(provider_id)
|
||||
controller = DatasourceManager.get_datasource_plugin_provider(
|
||||
provider_id=canonical_provider_id,
|
||||
tenant_id=tenant.id,
|
||||
datasource_type=payload.datasource_type,
|
||||
)
|
||||
if controller.entity.provider_type != payload.datasource_type:
|
||||
raise ValueError("Datasource provider type mismatch")
|
||||
|
||||
# Resolving the datasource from the installed declaration prevents a caller
|
||||
# from dispatching an arbitrary datasource name under a valid plugin ID.
|
||||
runtime = controller.get_datasource(payload.datasource)
|
||||
credentials = DatasourceProviderService().get_datasource_credentials(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider_id.provider_name,
|
||||
plugin_id=provider_id.plugin_id,
|
||||
credential_id=payload.credential_id,
|
||||
)
|
||||
if controller.need_credentials and not credentials:
|
||||
raise ValueError("Datasource credential not found")
|
||||
|
||||
if payload.operation == "validate_credentials":
|
||||
controller.validate_credentials(user_id=user_id, credentials=credentials)
|
||||
yield {"result": True}
|
||||
return
|
||||
|
||||
runtime.runtime.credentials = credentials
|
||||
provider_type = runtime.datasource_provider_type()
|
||||
|
||||
match payload.operation:
|
||||
case "get_website_crawl":
|
||||
website = cast(WebsiteCrawlDatasourcePlugin, runtime)
|
||||
yield from website.get_website_crawl(
|
||||
user_id=user_id,
|
||||
datasource_parameters=payload.datasource_parameters,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
case "get_online_document_pages":
|
||||
document = cast(OnlineDocumentDatasourcePlugin, runtime)
|
||||
yield from document.get_online_document_pages(
|
||||
user_id=user_id,
|
||||
datasource_parameters=payload.datasource_parameters,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
case "get_online_document_page_content":
|
||||
if payload.page is None:
|
||||
raise ValueError("Online-document page input is required")
|
||||
document = cast(OnlineDocumentDatasourcePlugin, runtime)
|
||||
yield from document.get_online_document_page_content(
|
||||
user_id=user_id,
|
||||
datasource_parameters=payload.page,
|
||||
provider_type=provider_type,
|
||||
)
|
||||
case "online_drive_browse_files":
|
||||
drive = cast(OnlineDriveDatasourcePlugin, runtime)
|
||||
yield from drive.online_drive_browse_files(
|
||||
user_id=user_id,
|
||||
request=OnlineDriveBrowseFilesRequest.model_validate(payload.request),
|
||||
provider_type=provider_type,
|
||||
)
|
||||
case "online_drive_download_file":
|
||||
drive = cast(OnlineDriveDatasourcePlugin, runtime)
|
||||
yield from drive.online_drive_download_file(
|
||||
user_id=user_id,
|
||||
request=OnlineDriveDownloadFileRequest.model_validate(payload.request),
|
||||
provider_type=provider_type,
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported datasource operation: {payload.operation}")
|
||||
@@ -1,8 +1,12 @@
|
||||
import tempfile
|
||||
from binascii import hexlify, unhexlify
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Mapping
|
||||
from enum import Enum
|
||||
from operator import itemgetter
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.base.tts.audio_mime import get_model_audio_mime_type, inspect_audio_stream
|
||||
from core.credit_usage import CreditUsageCreatedBy
|
||||
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
|
||||
@@ -10,15 +14,22 @@ from core.model_context import with_credit_usage_created_by
|
||||
from core.model_manager import ModelManager
|
||||
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
|
||||
from core.plugin.entities.request import (
|
||||
InvokableModelCatalogItem,
|
||||
InvokableModelCatalogPage,
|
||||
InvokableModelTokenLimits,
|
||||
RequestInvokeLLM,
|
||||
RequestInvokeLLMWithStructuredOutput,
|
||||
RequestInvokeModeration,
|
||||
RequestInvokeMultimodalEmbedding,
|
||||
RequestInvokeRerank,
|
||||
RequestInvokeSpeech2Text,
|
||||
RequestInvokeSummary,
|
||||
RequestInvokeTextEmbedding,
|
||||
RequestInvokeTTS,
|
||||
RequestListModels,
|
||||
)
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.utils.model_invocation_utils import ModelInvocationUtils
|
||||
from graphon.model_runtime.entities.llm_entities import (
|
||||
@@ -35,9 +46,62 @@ from graphon.model_runtime.entities.message_entities import (
|
||||
)
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.account import Tenant
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
def _json_compatible(value: Any) -> Any:
|
||||
"""Convert model-runtime metadata into stable JSON-compatible values."""
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(_json_compatible(key)): _json_compatible(child) for key, child in value.items()}
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [_json_compatible(child) for child in value]
|
||||
return value
|
||||
|
||||
|
||||
class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
|
||||
@classmethod
|
||||
def _model_token_limits(
|
||||
cls, *, tenant_id: str, user_id: str, provider: str, model: str
|
||||
) -> InvokableModelTokenLimits | None:
|
||||
"""Resolve the selected model only; listing a catalog must not cause N schema RPCs.
|
||||
|
||||
Custom-model settings (including the configured max-token rule) are resolved by the same
|
||||
tenant-bound runtime as invocation. Never infer an output maximum from a rule's default.
|
||||
"""
|
||||
instance = cls._get_bound_model_instance(
|
||||
tenant_id=tenant_id, user_id=user_id, provider=provider, model_type=ModelType.LLM, model=model
|
||||
)
|
||||
schema = instance.get_model_schema()
|
||||
if schema is None:
|
||||
return None
|
||||
|
||||
def positive_limit(value: Any) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
if not 0 < value <= 1_000_000_000 or value != int(value):
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
output_rules = [
|
||||
rule
|
||||
for rule in schema.parameter_rules
|
||||
if rule.name in {"max_tokens", "max_output_tokens", "max_completion_tokens", "maxtoken"}
|
||||
or rule.use_template == "max_tokens"
|
||||
]
|
||||
bounded_rules = [(rule, positive_limit(rule.max)) for rule in output_rules]
|
||||
bounded_rules = [(rule, limit) for rule, limit in bounded_rules if limit is not None]
|
||||
# Multiple declared aliases must not let the caller bypass the tighter bound.
|
||||
selected = min(bounded_rules, key=itemgetter(1)) if bounded_rules else None
|
||||
return InvokableModelTokenLimits(
|
||||
context_tokens=positive_limit(schema.model_properties.get("context_size")),
|
||||
max_output_tokens=selected[1] if selected else None,
|
||||
output_parameter=selected[0].name if selected else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@with_credit_usage_created_by(CreditUsageCreatedBy.PLUGIN_API)
|
||||
def _get_bound_model_instance(
|
||||
@@ -178,7 +242,30 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
|
||||
)
|
||||
|
||||
# invoke model
|
||||
response = model_instance.invoke_text_embedding(texts=payload.texts)
|
||||
response = model_instance.invoke_text_embedding(texts=payload.texts, input_type=payload.input_type)
|
||||
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def invoke_multimodal_embedding(
|
||||
cls,
|
||||
user_id: str,
|
||||
tenant: Tenant,
|
||||
payload: RequestInvokeMultimodalEmbedding,
|
||||
):
|
||||
"""Invoke multimodal embedding through the tenant-bound model instance."""
|
||||
model_instance = cls._get_bound_model_instance(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user_id,
|
||||
provider=payload.provider,
|
||||
model_type=payload.model_type,
|
||||
model=payload.model,
|
||||
)
|
||||
|
||||
response = model_instance.invoke_multimodal_embedding(
|
||||
multimodel_documents=[document.model_dump(exclude_none=True) for document in payload.documents],
|
||||
input_type=payload.input_type,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -205,6 +292,75 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
|
||||
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def list_models(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
payload: RequestListModels,
|
||||
) -> InvokableModelCatalogPage:
|
||||
"""List only models that are active for the tenant's Dify configuration."""
|
||||
provider_manager = create_plugin_provider_manager(tenant_id=tenant_id, user_id=user_id)
|
||||
active_models = provider_manager.get_configurations(tenant_id).get_models(
|
||||
model_type=payload.model_type,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
installed_identities: dict[str, str] = {}
|
||||
for plugin in PluginService.list(tenant_id):
|
||||
existing = installed_identities.get(plugin.plugin_id)
|
||||
if existing is not None and existing != plugin.plugin_unique_identifier:
|
||||
raise ValueError(f"Ambiguous installed identity for model plugin {plugin.plugin_id}")
|
||||
installed_identities[plugin.plugin_id] = plugin.plugin_unique_identifier
|
||||
|
||||
requested_provider = str(ModelProviderID(payload.provider)) if payload.provider else None
|
||||
matched_models = [
|
||||
model
|
||||
for model in active_models
|
||||
if (requested_provider is None or model.provider.provider == requested_provider)
|
||||
and (payload.model is None or model.model == payload.model)
|
||||
]
|
||||
matched_models.sort(key=lambda model: (model.provider.provider, model.model))
|
||||
|
||||
page_models = matched_models[payload.offset : payload.offset + payload.limit]
|
||||
items: list[InvokableModelCatalogItem] = []
|
||||
for model in page_models:
|
||||
provider_id = ModelProviderID(model.provider.provider)
|
||||
unique_identifier = installed_identities.get(provider_id.plugin_id)
|
||||
if unique_identifier is None:
|
||||
raise ValueError(f"Installed identity not found for active model plugin {provider_id.plugin_id}")
|
||||
items.append(
|
||||
InvokableModelCatalogItem(
|
||||
plugin_id=provider_id.plugin_id,
|
||||
plugin_unique_identifier=unique_identifier,
|
||||
provider=provider_id.provider_name,
|
||||
model=model.model,
|
||||
model_type=model.model_type,
|
||||
capabilities={
|
||||
"deprecated": model.deprecated,
|
||||
"features": _json_compatible(model.features or []),
|
||||
"fetchFrom": _json_compatible(model.fetch_from),
|
||||
"modelProperties": _json_compatible(model.model_properties),
|
||||
"modelType": model.model_type.value,
|
||||
"status": _json_compatible(model.status),
|
||||
},
|
||||
token_limits=cls._model_token_limits(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
provider=model.provider.provider,
|
||||
model=model.model,
|
||||
)
|
||||
if payload.model and model.model_type == ModelType.LLM
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
next_offset = payload.offset + len(page_models)
|
||||
return InvokableModelCatalogPage(
|
||||
items=items,
|
||||
next_offset=next_offset if next_offset < len(matched_models) else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def invoke_tts(cls, user_id: str, tenant: Tenant, payload: RequestInvokeTTS):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,11 @@ from typing import Any, Literal
|
||||
from flask import Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from core.datasource.entities.datasource_entities import (
|
||||
DatasourceProviderType,
|
||||
GetOnlineDocumentPageContentRequest,
|
||||
)
|
||||
from core.entities.embedding_type import EmbeddingInputType
|
||||
from core.entities.provider_entities import BasicProviderConfig
|
||||
from core.plugin.utils.http_parser import deserialize_response
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
@@ -54,6 +59,61 @@ class RequestInvokeTool(BaseModel):
|
||||
credential_id: str | None = None
|
||||
|
||||
|
||||
DatasourceInvocationOperation = Literal[
|
||||
"get_online_document_page_content",
|
||||
"get_online_document_pages",
|
||||
"get_website_crawl",
|
||||
"online_drive_browse_files",
|
||||
"online_drive_download_file",
|
||||
"validate_credentials",
|
||||
]
|
||||
|
||||
|
||||
class RequestInvokeDatasource(BaseModel):
|
||||
"""Invoke one installed datasource using a Dify-owned credential reference.
|
||||
|
||||
Raw credentials are intentionally not part of this contract. ``tenant_id`` and
|
||||
``user_id`` are consumed by the inner-API request context, while the remaining
|
||||
fields select an installed provider declaration and an operation-specific input.
|
||||
"""
|
||||
|
||||
tenant_id: str = Field(min_length=1, max_length=512)
|
||||
user_id: str = Field(min_length=1, max_length=512)
|
||||
provider: str = Field(min_length=1, max_length=768)
|
||||
datasource: str = Field(min_length=1, max_length=256)
|
||||
datasource_type: DatasourceProviderType
|
||||
credential_id: str = Field(min_length=1, max_length=512)
|
||||
operation: DatasourceInvocationOperation
|
||||
datasource_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
page: GetOnlineDocumentPageContentRequest | None = None
|
||||
request: dict[str, Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_operation_payload(self) -> "RequestInvokeDatasource":
|
||||
expected_type = {
|
||||
"get_online_document_page_content": DatasourceProviderType.ONLINE_DOCUMENT,
|
||||
"get_online_document_pages": DatasourceProviderType.ONLINE_DOCUMENT,
|
||||
"get_website_crawl": DatasourceProviderType.WEBSITE_CRAWL,
|
||||
"online_drive_browse_files": DatasourceProviderType.ONLINE_DRIVE,
|
||||
"online_drive_download_file": DatasourceProviderType.ONLINE_DRIVE,
|
||||
"validate_credentials": self.datasource_type,
|
||||
}[self.operation]
|
||||
if self.datasource_type != expected_type:
|
||||
raise ValueError(f"{self.operation} requires datasource_type {expected_type.value}")
|
||||
|
||||
page_required = self.operation == "get_online_document_page_content"
|
||||
if page_required != (self.page is not None):
|
||||
raise ValueError("page is required only for get_online_document_page_content")
|
||||
|
||||
request_required = self.operation in {"online_drive_browse_files", "online_drive_download_file"}
|
||||
if request_required != (self.request is not None):
|
||||
raise ValueError("request is required only for online-drive operations")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class BaseRequestInvokeModel(BaseModel):
|
||||
provider: str
|
||||
model: str
|
||||
@@ -115,6 +175,25 @@ class RequestInvokeTextEmbedding(BaseRequestInvokeModel):
|
||||
|
||||
model_type: ModelType = ModelType.TEXT_EMBEDDING
|
||||
texts: list[str]
|
||||
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT
|
||||
|
||||
|
||||
class MultimodalEmbeddingDocument(BaseModel):
|
||||
"""A document accepted by a multimodal text-embedding model."""
|
||||
|
||||
content: str
|
||||
content_type: str
|
||||
file_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RequestInvokeMultimodalEmbedding(BaseRequestInvokeModel):
|
||||
"""Request to invoke a multimodal text-embedding model."""
|
||||
|
||||
model_type: ModelType = ModelType.TEXT_EMBEDDING
|
||||
documents: list[MultimodalEmbeddingDocument] = Field(min_length=1)
|
||||
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT
|
||||
|
||||
|
||||
class RequestInvokeRerank(BaseRequestInvokeModel):
|
||||
@@ -125,8 +204,49 @@ class RequestInvokeRerank(BaseRequestInvokeModel):
|
||||
model_type: ModelType = ModelType.RERANK
|
||||
query: str
|
||||
docs: list[str]
|
||||
score_threshold: float
|
||||
top_n: int
|
||||
score_threshold: float | None = None
|
||||
top_n: int | None = None
|
||||
|
||||
|
||||
class RequestListModels(BaseModel):
|
||||
"""Tenant-scoped query for models that Dify can invoke."""
|
||||
|
||||
model_type: Literal[ModelType.LLM, ModelType.TEXT_EMBEDDING, ModelType.RERANK]
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
offset: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
class InvokableModelTokenLimits(BaseModel):
|
||||
"""Provider-declared limits, not completion defaults or credentials."""
|
||||
|
||||
context_tokens: int | None = Field(default=None, gt=0, le=1_000_000_000)
|
||||
max_output_tokens: int | None = Field(default=None, gt=0, le=1_000_000_000)
|
||||
output_parameter: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
|
||||
|
||||
class InvokableModelCatalogItem(BaseModel):
|
||||
"""Installed identity and active Dify capability metadata for one model."""
|
||||
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
provider: str
|
||||
model: str
|
||||
model_type: ModelType
|
||||
capabilities: dict[str, Any] = Field(default_factory=dict)
|
||||
token_limits: InvokableModelTokenLimits | None = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
class InvokableModelCatalogPage(BaseModel):
|
||||
"""Offset page returned by the internal model catalog endpoint."""
|
||||
|
||||
items: list[InvokableModelCatalogItem] = Field(default_factory=list)
|
||||
next_offset: int | None = None
|
||||
|
||||
|
||||
class RequestInvokeTTS(BaseRequestInvokeModel):
|
||||
|
||||
@@ -8,6 +8,7 @@ from core.datasource.entities.datasource_entities import (
|
||||
OnlineDriveBrowseFilesRequest,
|
||||
OnlineDriveBrowseFilesResponse,
|
||||
OnlineDriveDownloadFileRequest,
|
||||
OnlineDriveDownloadMessage,
|
||||
WebsiteCrawlMessage,
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
@@ -295,7 +296,7 @@ class PluginDatasourceManager(BasePluginClient):
|
||||
response = self._request_with_plugin_daemon_response_stream(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/dispatch/datasource/online_drive_download_file",
|
||||
DatasourceMessage,
|
||||
OnlineDriveDownloadMessage,
|
||||
data={
|
||||
"user_id": user_id,
|
||||
"data": {
|
||||
|
||||
@@ -10,7 +10,6 @@ from core.rag.rerank.entity.weight import KeywordSetting, VectorSetting, Weights
|
||||
from core.rag.rerank.rerank_base import BaseRerankRunner
|
||||
from core.rag.rerank.rerank_factory import RerankRunnerFactory
|
||||
from core.rag.rerank.rerank_type import RerankMode
|
||||
from extensions.otel import trace_span
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
|
||||
|
||||
@@ -53,7 +52,6 @@ class DataPostProcessor:
|
||||
)
|
||||
self.reorder_runner = self._get_reorder_runner(reorder_enabled)
|
||||
|
||||
@trace_span()
|
||||
def invoke(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from flask import Flask, current_app
|
||||
from opentelemetry import context as otel_context
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
@@ -24,7 +26,7 @@ from core.rag.rerank.rerank_type import RerankMode
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from core.tools.signature import sign_upload_file_preview_url
|
||||
from extensions.ext_database import db
|
||||
from extensions.otel import propagate_context, trace_span
|
||||
from extensions.otel import trace_span
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.dataset import (
|
||||
ChildChunk,
|
||||
@@ -90,6 +92,20 @@ default_retrieval_model: DefaultRetrievalModelDict = {
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _propagate_otel_context[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||
captured_context = otel_context.get_current()
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
token = otel_context.attach(captured_context)
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RetrievalService:
|
||||
# Cache precompiled regular expressions to avoid repeated compilation
|
||||
@classmethod
|
||||
@@ -123,7 +139,7 @@ class RetrievalService:
|
||||
if query:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(retrieval_service._retrieve),
|
||||
_propagate_otel_context(retrieval_service._retrieve),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
retrieval_method=retrieval_method,
|
||||
dataset=dataset,
|
||||
@@ -143,7 +159,7 @@ class RetrievalService:
|
||||
for attachment_id in attachment_ids:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(retrieval_service._retrieve),
|
||||
_propagate_otel_context(retrieval_service._retrieve),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
retrieval_method=retrieval_method,
|
||||
dataset=dataset,
|
||||
@@ -804,7 +820,7 @@ class RetrievalService:
|
||||
if retrieval_method == RetrievalMethod.KEYWORD_SEARCH and query:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(self.keyword_search),
|
||||
_propagate_otel_context(self.keyword_search),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
dataset_id=dataset.id,
|
||||
query=query,
|
||||
@@ -818,7 +834,7 @@ class RetrievalService:
|
||||
if query:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(self.embedding_search),
|
||||
_propagate_otel_context(self.embedding_search),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
dataset_id=dataset.id,
|
||||
query=query,
|
||||
@@ -835,7 +851,7 @@ class RetrievalService:
|
||||
if attachment_id:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(self.embedding_search),
|
||||
_propagate_otel_context(self.embedding_search),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
dataset_id=dataset.id,
|
||||
query=attachment_id,
|
||||
@@ -852,7 +868,7 @@ class RetrievalService:
|
||||
if RetrievalMethod.is_support_fulltext_search(retrieval_method) and query:
|
||||
futures.append(
|
||||
executor.submit(
|
||||
propagate_context(self.full_text_index_search),
|
||||
_propagate_otel_context(self.full_text_index_search),
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
dataset_id=dataset.id,
|
||||
query=query,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from typing import Any
|
||||
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsCitation
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RetrievalSourceMetadata(BaseModel):
|
||||
knowledge_fs_citation: KnowledgeFsCitation | None = None
|
||||
position: int | None = None
|
||||
dataset_id: str | None = None
|
||||
dataset_name: str | None = None
|
||||
document_id: str | None = None
|
||||
document_asset_id: str | None = None
|
||||
document_name: str | None = None
|
||||
document_revision: int | None = None
|
||||
document_version: int | None = None
|
||||
data_source_type: str | None = None
|
||||
segment_id: str | None = None
|
||||
retriever_from: str | None = None
|
||||
|
||||
@@ -58,7 +58,7 @@ Here is the chat example between human and assistant, inside <example></example>
|
||||
User:{{"input_text": ["I want to know which company’s email address test@example.com is?"], "metadata_fields": ["filename", "email", "phone", "address"]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "email", "metadata_field_value": "test@example.com", "comparison_operator": "="}}]}}
|
||||
User:{{"input_text": "What are the movies with a score of more than 9 in 2024?", "metadata_fields": ["name", "year", "rating", "country"]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
|
||||
</example>
|
||||
### User Input
|
||||
{{"input_text" : "{input_text}", "metadata_fields" : {metadata_fields}}}
|
||||
|
||||
@@ -10,6 +10,7 @@ class RBACResourceScope(StrEnum):
|
||||
|
||||
APP = "app"
|
||||
DATASET = "dataset"
|
||||
KNOWLEDGE_FS = "knowledge_space"
|
||||
AGENT = "agent"
|
||||
WORKSPACE = "workspace"
|
||||
|
||||
@@ -71,6 +72,14 @@ class RBACPermission(StrEnum):
|
||||
DATASET_EXTERNAL_CONNECT = "dataset_external_connect"
|
||||
DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl"
|
||||
|
||||
KNOWLEDGE_FS_READ = "knowledge_space_read"
|
||||
KNOWLEDGE_FS_CREATE = "knowledge_space_create"
|
||||
KNOWLEDGE_FS_EDIT = "knowledge_space_edit"
|
||||
KNOWLEDGE_FS_DELETE = "knowledge_space_delete"
|
||||
KNOWLEDGE_FS_ACCESS_CONFIG = "knowledge_space_access_config"
|
||||
KNOWLEDGE_FS_DOCUMENT_WRITE = "knowledge_space_document_write"
|
||||
KNOWLEDGE_FS_QUERY = "knowledge_space_query"
|
||||
|
||||
WORKSPACE_MEMBER_MANAGE = "workspace_member_manage"
|
||||
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
|
||||
API_EXTENSION_MANAGE = "api_extension_manage"
|
||||
|
||||
@@ -58,6 +58,7 @@ class Tool(ABC):
|
||||
if self.runtime and self.runtime.runtime_parameters:
|
||||
tool_parameters.update(self.runtime.runtime_parameters)
|
||||
|
||||
# try parse tool parameters into the correct type
|
||||
tool_parameters = self._transform_tool_parameters_type(tool_parameters)
|
||||
|
||||
result = self._invoke(
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.entities.tool_entities import ToolInvokeFrom
|
||||
|
||||
@@ -20,6 +20,7 @@ class ToolRuntime(BaseModel):
|
||||
tool_id: str | None = None
|
||||
invoke_from: InvokeFrom | None = None
|
||||
tool_invoke_from: ToolInvokeFrom | None = None
|
||||
dify_run_context: DifyRunContext | None = Field(default=None, exclude=True, repr=False)
|
||||
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||
credential_type: CredentialType = Field(default=CredentialType.API_KEY)
|
||||
runtime_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
- audio
|
||||
- code
|
||||
- knowledge_fs
|
||||
- time
|
||||
- webscraper
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#155EEF"/>
|
||||
<path d="M17 17h20c5.5 0 10 4.5 10 10v20H27c-5.5 0-10-4.5-10-10V17Z" fill="white" fill-opacity=".96"/>
|
||||
<path d="M26 27h12M26 34h12M26 41h7" stroke="#155EEF" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,9 @@
|
||||
from typing import Any, override
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
|
||||
|
||||
class KnowledgeFSProvider(BuiltinToolProviderController):
|
||||
@override
|
||||
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None:
|
||||
_ = (user_id, credentials)
|
||||
@@ -0,0 +1,12 @@
|
||||
identity:
|
||||
author: Dify
|
||||
name: knowledge_fs
|
||||
label:
|
||||
en_US: Agent Knowledge Base
|
||||
zh_Hans: Agent 知识库
|
||||
description:
|
||||
en_US: Run explicitly bound Agent Knowledge Base operations from an Agent or Workflow.
|
||||
zh_Hans: 从 Agent 或工作流执行显式绑定的 Agent 知识库操作。
|
||||
icon: icon.svg
|
||||
tags:
|
||||
- rag
|
||||
@@ -0,0 +1,60 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.tools.builtin_tool.tool import BuiltinTool
|
||||
from core.tools.entities.tool_entities import ToolInvokeFrom, ToolInvokeMessage
|
||||
from core.tools.errors import ToolInvokeError
|
||||
from models.knowledge_fs import KnowledgeFSAppSpaceJoinType
|
||||
from services.knowledge_fs.app_execution_capability import KnowledgeResourceRef
|
||||
from services.knowledge_fs.product_dto import KnowledgeFSResearchTaskCreatePayload
|
||||
from services.knowledge_fs.runtime import create_knowledge_fs_runtime
|
||||
|
||||
|
||||
class KnowledgeFSCreateResearchTaskTool(BuiltinTool):
|
||||
@override
|
||||
def _invoke(
|
||||
self,
|
||||
session: Session,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> Generator[ToolInvokeMessage, None, None]:
|
||||
_ = (user_id, conversation_id, app_id, message_id)
|
||||
run_context = self.runtime.dify_run_context
|
||||
if run_context is None or self.runtime.tenant_id != run_context.tenant_id:
|
||||
raise ToolInvokeError("KnowledgeFS requires a trusted Dify run context")
|
||||
match self.runtime.tool_invoke_from:
|
||||
case ToolInvokeFrom.AGENT:
|
||||
caller_kind = KnowledgeFSAppSpaceJoinType.AGENT
|
||||
case ToolInvokeFrom.WORKFLOW:
|
||||
caller_kind = KnowledgeFSAppSpaceJoinType.WORKFLOW
|
||||
case _:
|
||||
raise ToolInvokeError("KnowledgeFS is only available to Agent and Workflow callers")
|
||||
|
||||
try:
|
||||
resource = KnowledgeResourceRef.model_validate(tool_parameters.get("resource"))
|
||||
payload_data: dict[str, object] = {
|
||||
"query": tool_parameters.get("query"),
|
||||
}
|
||||
mode = tool_parameters.get("mode")
|
||||
if mode:
|
||||
payload_data["mode"] = mode
|
||||
payload = KnowledgeFSResearchTaskCreatePayload.model_validate(payload_data)
|
||||
runtime = create_knowledge_fs_runtime(sessionmaker(bind=session.get_bind(), expire_on_commit=False))
|
||||
response = runtime.app_capabilities.create_research_task(
|
||||
run_context=run_context,
|
||||
caller_kind=caller_kind,
|
||||
resource=resource,
|
||||
payload=payload,
|
||||
)
|
||||
except ToolInvokeError:
|
||||
raise
|
||||
except (ValidationError, RuntimeError, ValueError) as exc:
|
||||
raise ToolInvokeError(str(exc)) from exc
|
||||
|
||||
yield self.create_json_message(response.model_dump(mode="json", by_alias=True))
|
||||
@@ -0,0 +1,74 @@
|
||||
identity:
|
||||
name: create_research_task
|
||||
author: Dify
|
||||
label:
|
||||
en_US: Create Agent Knowledge Base Research Task
|
||||
zh_Hans: 创建 Agent 知识库研究任务
|
||||
description:
|
||||
human:
|
||||
en_US: Create a Research task in an explicitly bound Agent Knowledge Base.
|
||||
zh_Hans: 在显式绑定的 Agent 知识库中创建研究任务。
|
||||
llm: Create a durable research task using an explicitly configured Agent Knowledge Base.
|
||||
parameters:
|
||||
- name: resource
|
||||
type: object
|
||||
required: true
|
||||
label:
|
||||
en_US: Agent Knowledge Base
|
||||
zh_Hans: Agent 知识库
|
||||
human_description:
|
||||
en_US: A reference to an Agent Knowledge Base configured by the app author.
|
||||
zh_Hans: 由应用作者配置的 Agent 知识库引用。
|
||||
form: form
|
||||
input_schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: knowledge_fs
|
||||
control_space_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- kind
|
||||
- control_space_id
|
||||
- name: query
|
||||
type: string
|
||||
required: true
|
||||
label:
|
||||
en_US: Research query
|
||||
zh_Hans: 研究查询
|
||||
human_description:
|
||||
en_US: The question the Research task should investigate.
|
||||
zh_Hans: 研究任务需要调查的问题。
|
||||
llm_description: The question to investigate with the Agent Knowledge Base.
|
||||
form: llm
|
||||
- name: mode
|
||||
type: select
|
||||
required: false
|
||||
label:
|
||||
en_US: Mode
|
||||
zh_Hans: 模式
|
||||
human_description:
|
||||
en_US: Optional Agent Knowledge Base retrieval mode.
|
||||
zh_Hans: 可选的 Agent 知识库检索模式。
|
||||
llm_description: Optional retrieval mode. Use auto unless the task needs a specific mode.
|
||||
form: llm
|
||||
options:
|
||||
- value: auto
|
||||
label:
|
||||
en_US: Auto
|
||||
zh_Hans: 自动
|
||||
- value: fast
|
||||
label:
|
||||
en_US: Fast
|
||||
zh_Hans: 快速
|
||||
- value: deep
|
||||
label:
|
||||
en_US: Deep
|
||||
zh_Hans: 深度
|
||||
- value: research
|
||||
label:
|
||||
en_US: Research
|
||||
zh_Hans: 研究
|
||||
@@ -39,6 +39,7 @@ from core.workflow.node_runtime import (
|
||||
DifyToolFileManager,
|
||||
DifyToolNodeRuntime,
|
||||
build_dify_llm_file_saver,
|
||||
build_knowledge_fs_node_dependencies,
|
||||
)
|
||||
from core.workflow.nodes.agent.message_transformer import AgentMessageTransformer
|
||||
from core.workflow.nodes.agent.plugin_strategy_adapter import (
|
||||
@@ -53,6 +54,10 @@ from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapt
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
|
||||
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData
|
||||
from core.workflow.nodes.knowledge_retrieval_v2.automatic_metadata_filter import (
|
||||
KnowledgeFSAutomaticMetadataFilterExtractor,
|
||||
)
|
||||
from core.workflow.nodes.knowledge_retrieval_v2.entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
|
||||
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
@@ -491,6 +496,13 @@ class DifyNodeFactory(NodeFactory):
|
||||
"runtime": self._tool_runtime,
|
||||
},
|
||||
BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class),
|
||||
KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE: lambda: {
|
||||
**build_knowledge_fs_node_dependencies(),
|
||||
"metadata_filter_extractor": KnowledgeFSAutomaticMetadataFilterExtractor(
|
||||
credentials_provider=self._llm_credentials_provider,
|
||||
model_factory=self._llm_model_factory,
|
||||
),
|
||||
},
|
||||
}
|
||||
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
|
||||
constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload, override
|
||||
|
||||
from flask import current_app
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -560,6 +561,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
self._run_context.invoke_from,
|
||||
variable_pool,
|
||||
)
|
||||
tool_runtime.runtime.dify_run_context = self._run_context
|
||||
except ToolNodeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -1010,3 +1012,9 @@ def build_dify_llm_file_saver(
|
||||
file_reference_factory=DifyFileReferenceFactory(run_context),
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
def build_knowledge_fs_node_dependencies() -> dict[str, object]:
|
||||
"""Resolve the retrieval capabilities registered by the application composition root."""
|
||||
factory = cast(Callable[[], dict[str, object]], current_app.extensions["knowledge_fs_node_dependencies"])
|
||||
return factory()
|
||||
|
||||
@@ -24,6 +24,7 @@ from clients.agent_backend import (
|
||||
AgentBackendValidationError,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.app.knowledge_citations import knowledge_sources_from_stream_event
|
||||
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.session_binding import default_session_binding
|
||||
@@ -37,7 +38,7 @@ from graphon.enums import (
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.graph_events import NodeRunPauseRequestedEvent
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, RunRetrieverResourceEvent, StreamCompletedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowOutputRoutes
|
||||
@@ -360,6 +361,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
if metadata.get("knowledge_fs_sources"):
|
||||
yield RunRetrieverResourceEvent(retriever_resources=metadata["knowledge_fs_sources"], context="")
|
||||
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
|
||||
if (
|
||||
isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent)
|
||||
@@ -827,6 +830,18 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
if event.event_kind:
|
||||
agent_backend["last_stream_event_kind"] = event.event_kind
|
||||
if isinstance(event.data, Mapping):
|
||||
sources = knowledge_sources_from_stream_event(event.data)
|
||||
if sources:
|
||||
current = metadata.setdefault("knowledge_fs_sources", [])
|
||||
seen = {source["knowledge_fs_citation"]["id"] for source in current}
|
||||
for source in sources:
|
||||
if (
|
||||
source.knowledge_fs_citation
|
||||
and source.knowledge_fs_citation.id not in seen
|
||||
and len(current) < 200
|
||||
):
|
||||
current.append(source.model_dump(mode="json"))
|
||||
seen.add(source.knowledge_fs_citation.id)
|
||||
usage = event.data.get("usage") or event.data.get("model_usage")
|
||||
if isinstance(usage, Mapping):
|
||||
agent_backend["usage"] = dict(usage)
|
||||
|
||||
@@ -49,7 +49,13 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
|
||||
reserved_status["knowledge"] = (
|
||||
"legacy_rebind_required"
|
||||
if agent_soul.knowledge.sets
|
||||
else "supported_by_knowledge_fs_cli"
|
||||
if agent_soul.knowledge.spaces
|
||||
else "not_configured"
|
||||
)
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
|
||||
@@ -36,6 +36,7 @@ from dify_agent.layers.shell import (
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from dify_agent.protocol.knowledge_fs import KnowledgeFsError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
@@ -48,6 +49,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.knowledge_runtime_config import build_knowledge_fs_layer_config
|
||||
from core.app.llm.model_access import resolve_model_context_window
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
|
||||
@@ -220,7 +222,10 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
try:
|
||||
knowledge_config = build_knowledge_fs_layer_config(agent_soul, run_context=context.dify_context)
|
||||
except KnowledgeFsError as exc:
|
||||
raise WorkflowAgentRuntimeRequestBuildError(exc.code, exc.message) from exc
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
@@ -261,6 +266,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
agent_config_version_kind="snapshot",
|
||||
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
trace_id=context.dify_context.trace_session_id,
|
||||
),
|
||||
backend_binding_ref=context.backend_binding_ref,
|
||||
agent_soul_prompt=soul_prompt or None,
|
||||
@@ -271,7 +277,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
),
|
||||
tools=tool_layers.plugin_tools,
|
||||
core_tools=tool_layers.core_tools,
|
||||
knowledge=knowledge_config,
|
||||
knowledge_fs=knowledge_config,
|
||||
config_layer_config=config_layer_config,
|
||||
ask_human_config=build_ask_human_layer_config(agent_soul),
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
|
||||
@@ -58,7 +58,7 @@ Here is the chat example between human and assistant, inside <example></example>
|
||||
User:{{"input_text": ["I want to know which company’s email address test@example.com is?"], "metadata_fields": ["filename", "email", "phone", "address"]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "email", "metadata_field_value": "test@example.com", "comparison_operator": "="}}]}}
|
||||
User:{{"input_text": "What are the movies with a score of more than 9 in 2024?", "metadata_fields": ["name", "year", "rating", "country"]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
|
||||
Assistant:{{"metadata_map": [{{"metadata_field_name": "year", "metadata_field_value": "2024", "comparison_operator": "="}}, {{"metadata_field_name": "rating", "metadata_field_value": "9", "comparison_operator": ">"}}]}}
|
||||
</example>
|
||||
### User Input
|
||||
{{"input_text" : "{input_text}", "metadata_fields" : {metadata_fields}}}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""KnowledgeFS-native workflow retrieval node."""
|
||||
|
||||
from .knowledge_retrieval_v2_node import KnowledgeRetrievalV2Node
|
||||
|
||||
__all__ = ["KnowledgeRetrievalV2Node"]
|
||||
@@ -0,0 +1,377 @@
|
||||
"""LLM-driven custom metadata condition extraction for the KnowledgeFS retrieval node.
|
||||
|
||||
Mirrors the legacy Knowledge Retrieval node's automatic metadata filtering: the query is
|
||||
shown to an LLM together with the metadata catalog of the selected Spaces, and the returned
|
||||
``metadata_map`` is turned into typed KnowledgeFS custom metadata conditions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, ModelConfigWithCredentialsEntity
|
||||
from core.app.llm.model_access import DifyModelFactory, fetch_model_config
|
||||
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
|
||||
from core.knowledge_fs.retrieval_contracts import (
|
||||
KnowledgeFSMetadataFieldResponse,
|
||||
KnowledgeFSRetrievalCustomMetadataCondition,
|
||||
)
|
||||
from core.prompt.advanced_prompt_transform import AdvancedPromptTransform
|
||||
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate
|
||||
from core.prompt.simple_prompt_transform import ModelMode
|
||||
from core.rag.retrieval.template_prompts import (
|
||||
METADATA_FILTER_ASSISTANT_PROMPT_1,
|
||||
METADATA_FILTER_ASSISTANT_PROMPT_2,
|
||||
METADATA_FILTER_COMPLETION_PROMPT,
|
||||
METADATA_FILTER_SYSTEM_PROMPT,
|
||||
METADATA_FILTER_USER_PROMPT_1,
|
||||
METADATA_FILTER_USER_PROMPT_2,
|
||||
METADATA_FILTER_USER_PROMPT_3,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import (
|
||||
PromptMessage,
|
||||
PromptMessageRole,
|
||||
TextPromptMessageContent,
|
||||
)
|
||||
from graphon.nodes.llm.entities import ModelConfig
|
||||
from graphon.nodes.llm.protocols import CredentialsProvider
|
||||
from libs.json_in_md_parser import parse_and_check_json_markdown
|
||||
|
||||
from .exc import KnowledgeFSRetrievalConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MetadataFieldType = Literal["string", "number", "time"]
|
||||
|
||||
# The extraction prompt is shared with the legacy node and lets the model answer with generic
|
||||
# operators ("=", ">=", ...). KnowledgeFS validates operators per field type, so every
|
||||
# extracted operator is normalised to the vocabulary of the catalog field it targets.
|
||||
_OPERATOR_ALIASES: Mapping[MetadataFieldType, Mapping[str, str]] = {
|
||||
"number": {
|
||||
"=": "=",
|
||||
"==": "=",
|
||||
"is": "=",
|
||||
"≠": "≠",
|
||||
"!=": "≠",
|
||||
"is not": "≠",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
"≥": "≥",
|
||||
">=": "≥",
|
||||
"≤": "≤",
|
||||
"<=": "≤",
|
||||
"empty": "empty",
|
||||
"not empty": "not empty",
|
||||
},
|
||||
"string": {
|
||||
"contains": "contains",
|
||||
"not contains": "not contains",
|
||||
"start with": "start with",
|
||||
"end with": "end with",
|
||||
"is": "is",
|
||||
"=": "is",
|
||||
"==": "is",
|
||||
"is not": "is not",
|
||||
"≠": "is not",
|
||||
"!=": "is not",
|
||||
"empty": "empty",
|
||||
"not empty": "not empty",
|
||||
"in": "in",
|
||||
"not in": "not in",
|
||||
},
|
||||
"time": {
|
||||
"is": "is",
|
||||
"=": "is",
|
||||
"==": "is",
|
||||
"before": "before",
|
||||
"<": "before",
|
||||
"≤": "before",
|
||||
"<=": "before",
|
||||
"after": "after",
|
||||
">": "after",
|
||||
"≥": "after",
|
||||
">=": "after",
|
||||
"empty": "empty",
|
||||
"not empty": "not empty",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeFSMetadataFieldRef:
|
||||
name: str
|
||||
type: MetadataFieldType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeFSMetadataExtraction:
|
||||
"""Raw LLM extraction output plus the usage it cost."""
|
||||
|
||||
metadata_map: list[Mapping[str, Any]]
|
||||
usage: LLMUsage
|
||||
model: str
|
||||
provider: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeFSAutomaticMetadataFilterOutcome:
|
||||
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition]
|
||||
usage: LLMUsage
|
||||
applied: bool
|
||||
reason: str | None = None
|
||||
field_names: list[str] = field(default_factory=list)
|
||||
extracted_count: int = 0
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
|
||||
def as_metrics(self) -> dict[str, Any]:
|
||||
metrics: dict[str, Any] = {
|
||||
"applied": self.applied,
|
||||
"condition_count": len(self.conditions),
|
||||
"extracted_count": self.extracted_count,
|
||||
"field_names": list(self.field_names),
|
||||
"mode": "automatic",
|
||||
}
|
||||
if self.reason is not None:
|
||||
metrics["reason"] = self.reason
|
||||
if self.model is not None:
|
||||
metrics["model"] = self.model
|
||||
if self.provider is not None:
|
||||
metrics["provider"] = self.provider
|
||||
return metrics
|
||||
|
||||
|
||||
class KnowledgeFSMetadataFilterExtractor(Protocol):
|
||||
def extract(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
model_config: ModelConfig,
|
||||
query: str,
|
||||
field_names: Sequence[str],
|
||||
) -> KnowledgeFSMetadataExtraction: ...
|
||||
|
||||
|
||||
def intersect_metadata_fields(
|
||||
catalogs: Sequence[Sequence[KnowledgeFSMetadataFieldResponse]],
|
||||
) -> list[KnowledgeFSMetadataFieldRef]:
|
||||
"""Keep fields that every selected Space declares with the same name and type.
|
||||
|
||||
This mirrors the editor's catalog intersection so the LLM only sees fields the shared
|
||||
retrieval payload can legally filter on in every Space.
|
||||
"""
|
||||
|
||||
if not catalogs:
|
||||
return []
|
||||
first, *rest = catalogs
|
||||
remaining_types = [{item.name: item.type for item in group} for group in rest]
|
||||
fields: list[KnowledgeFSMetadataFieldRef] = []
|
||||
seen: set[str] = set()
|
||||
for item in first:
|
||||
if item.name in seen:
|
||||
continue
|
||||
if all(types.get(item.name) == item.type for types in remaining_types):
|
||||
seen.add(item.name)
|
||||
fields.append(KnowledgeFSMetadataFieldRef(name=item.name, type=item.type))
|
||||
return fields
|
||||
|
||||
|
||||
def build_metadata_filter_prompt(
|
||||
*,
|
||||
model_config: ModelConfigWithCredentialsEntity,
|
||||
mode: str,
|
||||
field_names: Sequence[str],
|
||||
query: str,
|
||||
) -> tuple[list[PromptMessage], list[str]]:
|
||||
model_mode = ModelMode(mode)
|
||||
metadata_fields = json.dumps(list(field_names), ensure_ascii=False)
|
||||
|
||||
prompt_template: list[ChatModelMessage] | CompletionModelPromptTemplate
|
||||
if model_mode == ModelMode.CHAT:
|
||||
prompt_template = [
|
||||
ChatModelMessage(role=PromptMessageRole.SYSTEM, text=METADATA_FILTER_SYSTEM_PROMPT),
|
||||
ChatModelMessage(role=PromptMessageRole.USER, text=METADATA_FILTER_USER_PROMPT_1),
|
||||
ChatModelMessage(role=PromptMessageRole.ASSISTANT, text=METADATA_FILTER_ASSISTANT_PROMPT_1),
|
||||
ChatModelMessage(role=PromptMessageRole.USER, text=METADATA_FILTER_USER_PROMPT_2),
|
||||
ChatModelMessage(role=PromptMessageRole.ASSISTANT, text=METADATA_FILTER_ASSISTANT_PROMPT_2),
|
||||
ChatModelMessage(
|
||||
role=PromptMessageRole.USER,
|
||||
text=METADATA_FILTER_USER_PROMPT_3.format(input_text=query, metadata_fields=metadata_fields),
|
||||
),
|
||||
]
|
||||
elif model_mode == ModelMode.COMPLETION:
|
||||
prompt_template = CompletionModelPromptTemplate(
|
||||
text=METADATA_FILTER_COMPLETION_PROMPT.format(input_text=query, metadata_fields=metadata_fields)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Model mode {model_mode} not support.")
|
||||
|
||||
prompt_messages = AdvancedPromptTransform().get_prompt(
|
||||
prompt_template=prompt_template,
|
||||
inputs={},
|
||||
query=query,
|
||||
files=[],
|
||||
context=None,
|
||||
memory_config=None,
|
||||
memory=None,
|
||||
model_config=model_config,
|
||||
)
|
||||
return prompt_messages, list(model_config.stop)
|
||||
|
||||
|
||||
def llm_result_text(result: LLMResult) -> str:
|
||||
content = result.message.content
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(item.data for item in content if isinstance(item, TextPromptMessageContent))
|
||||
return ""
|
||||
|
||||
|
||||
def parse_metadata_map(text: str) -> list[Mapping[str, Any]]:
|
||||
parsed = parse_and_check_json_markdown(text, [])
|
||||
if not isinstance(parsed, Mapping):
|
||||
return []
|
||||
metadata_map = parsed.get("metadata_map")
|
||||
if not isinstance(metadata_map, list):
|
||||
return []
|
||||
return [item for item in metadata_map if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _coerce_value(field_type: MetadataFieldType, value: object) -> str | int | float | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if field_type == "number":
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
number = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
if field_type == "time":
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
return str(value).strip()
|
||||
return value if isinstance(value, str) else str(value)
|
||||
|
||||
|
||||
def to_custom_metadata_conditions(
|
||||
metadata_map: Sequence[Mapping[str, Any]],
|
||||
catalog: Sequence[KnowledgeFSMetadataFieldRef],
|
||||
) -> list[KnowledgeFSRetrievalCustomMetadataCondition]:
|
||||
"""Convert LLM output into KnowledgeFS conditions, dropping anything outside the catalog contract."""
|
||||
|
||||
field_types = {item.name: item.type for item in catalog}
|
||||
conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = []
|
||||
for item in metadata_map:
|
||||
raw_name = item.get("metadata_field_name")
|
||||
raw_operator = item.get("comparison_operator")
|
||||
if not isinstance(raw_name, str) or not isinstance(raw_operator, str):
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
field_type = field_types.get(name)
|
||||
if field_type is None:
|
||||
continue
|
||||
operator = _OPERATOR_ALIASES[field_type].get(raw_operator.strip().lower())
|
||||
if operator is None:
|
||||
continue
|
||||
value = None
|
||||
if operator not in {"empty", "not empty"}:
|
||||
value = _coerce_value(field_type, item.get("metadata_field_value"))
|
||||
try:
|
||||
condition = KnowledgeFSRetrievalCustomMetadataCondition.model_validate(
|
||||
{
|
||||
"name": name,
|
||||
"field_type": field_type,
|
||||
"comparison_operator": operator,
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
except ValidationError:
|
||||
logger.info("Dropping automatic metadata condition outside the KnowledgeFS contract: %s", name)
|
||||
continue
|
||||
conditions.append(condition)
|
||||
return conditions
|
||||
|
||||
|
||||
class KnowledgeFSAutomaticMetadataFilterExtractor:
|
||||
"""Runs the shared metadata extraction prompt against the node's configured LLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credentials_provider: CredentialsProvider,
|
||||
model_factory: DifyModelFactory,
|
||||
) -> None:
|
||||
self._credentials_provider = credentials_provider
|
||||
self._model_factory = model_factory
|
||||
|
||||
def extract(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
model_config: ModelConfig,
|
||||
query: str,
|
||||
field_names: Sequence[str],
|
||||
) -> KnowledgeFSMetadataExtraction:
|
||||
try:
|
||||
model_instance, model_config_entity = fetch_model_config(
|
||||
node_data_model=model_config,
|
||||
credentials_provider=self._credentials_provider,
|
||||
model_factory=self._model_factory,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Agent Knowledge Base metadata filtering model is not configured or is unavailable"
|
||||
) from exc
|
||||
prompt_messages, stop = build_metadata_filter_prompt(
|
||||
model_config=model_config_entity,
|
||||
mode=model_config.mode,
|
||||
field_names=field_names,
|
||||
query=query,
|
||||
)
|
||||
result = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=model_config_entity.parameters,
|
||||
stop=stop,
|
||||
stream=False,
|
||||
request_metadata={
|
||||
"app_id": run_context.app_id,
|
||||
"app_type": run_context.app_type or CreditUsageAppType.UNKNOWN,
|
||||
"created_by": CreditUsageCreatedBy.KNOWLEDGE_RETRIEVAL,
|
||||
},
|
||||
)
|
||||
return KnowledgeFSMetadataExtraction(
|
||||
metadata_map=parse_metadata_map(llm_result_text(result)),
|
||||
usage=result.usage or LLMUsage.empty_usage(),
|
||||
model=model_instance.model_name,
|
||||
provider=model_instance.provider,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeFSAutomaticMetadataFilterExtractor",
|
||||
"KnowledgeFSAutomaticMetadataFilterOutcome",
|
||||
"KnowledgeFSMetadataExtraction",
|
||||
"KnowledgeFSMetadataFieldRef",
|
||||
"KnowledgeFSMetadataFilterExtractor",
|
||||
"build_metadata_filter_prompt",
|
||||
"intersect_metadata_fields",
|
||||
"llm_result_text",
|
||||
"parse_metadata_map",
|
||||
"to_custom_metadata_conditions",
|
||||
]
|
||||
98
api/core/workflow/nodes/knowledge_retrieval_v2/entities.py
Normal file
98
api/core/workflow/nodes/knowledge_retrieval_v2/entities.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from core.knowledge_fs.retrieval_contracts import KnowledgeFSRetrievalMetadataFilters
|
||||
from core.rag.entities import RerankingModelConfig, SupportedComparisonOperator
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import NodeType
|
||||
from graphon.nodes.llm.entities import ModelConfig
|
||||
|
||||
KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE = "knowledge-retrieval-v2"
|
||||
|
||||
ControlSpaceId = Annotated[str, Field(min_length=1, max_length=1_000)]
|
||||
VariableSelectorPart = Annotated[str, Field(min_length=1, max_length=255)]
|
||||
|
||||
|
||||
class KnowledgeRetrievalV2MetadataCondition(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
metadata_id: str | None = Field(default=None, min_length=1, max_length=512)
|
||||
metadata_type: Literal["string", "number", "time"]
|
||||
name: str = Field(min_length=1, max_length=255, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: str | int | float | None = None
|
||||
|
||||
|
||||
class KnowledgeRetrievalV2MetadataFilteringConditions(BaseModel):
|
||||
logical_operator: Literal["and", "or"] = "and"
|
||||
conditions: list[KnowledgeRetrievalV2MetadataCondition] = Field(default_factory=list, max_length=50)
|
||||
|
||||
|
||||
class KnowledgeRetrievalV2NodeData(BaseNodeData):
|
||||
"""Bounded, KnowledgeFS-native workflow node configuration."""
|
||||
|
||||
type: NodeType = KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
|
||||
control_space_ids: list[ControlSpaceId] = Field(min_length=1, max_length=10)
|
||||
query_variable_selector: list[VariableSelectorPart] | None = Field(default=None, max_length=10)
|
||||
query_attachment_selector: list[VariableSelectorPart] | None = Field(default=None, max_length=10)
|
||||
mode: Literal["deep", "fast", "research"] | None = None
|
||||
reranking_model: RerankingModelConfig | None = None
|
||||
score_threshold: float | None = Field(default=None, ge=0, le=1)
|
||||
top_n: int = Field(default=10, ge=1, le=100)
|
||||
metadata_filtering_mode: Literal["disabled", "automatic", "manual"] = "disabled"
|
||||
metadata_model_config: ModelConfig | None = None
|
||||
metadata_filtering_conditions: KnowledgeRetrievalV2MetadataFilteringConditions | None = None
|
||||
metadata_filters: KnowledgeFSRetrievalMetadataFilters | None = None
|
||||
|
||||
@field_validator("control_space_ids")
|
||||
@classmethod
|
||||
def normalize_control_space_ids(cls, values: list[str]) -> list[str]:
|
||||
normalized = [value.strip() for value in values]
|
||||
if any(not value for value in normalized):
|
||||
raise ValueError("Agent Knowledge Base IDs must be non-empty")
|
||||
return list(dict.fromkeys(normalized))
|
||||
|
||||
@field_validator("query_variable_selector")
|
||||
@classmethod
|
||||
def normalize_query_variable_selector(cls, values: list[str] | None) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
normalized = [value.strip() for value in values]
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) < 2:
|
||||
raise ValueError("Agent Knowledge Base query variable selector must contain at least two parts")
|
||||
if any(not value for value in normalized):
|
||||
raise ValueError("Agent Knowledge Base query variable selector must be non-empty")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_query_text_or_image(self) -> KnowledgeRetrievalV2NodeData:
|
||||
# Either modality can drive retrieval on its own; a node that binds neither can never run.
|
||||
if not self.query_variable_selector and not self.query_attachment_selector:
|
||||
raise ValueError("Agent Knowledge Retrieval requires a query text or a query image variable")
|
||||
return self
|
||||
|
||||
@field_validator("query_attachment_selector")
|
||||
@classmethod
|
||||
def normalize_query_attachment_selector(cls, values: list[str] | None) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
normalized = [value.strip() for value in values]
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) < 2:
|
||||
raise ValueError("Agent Knowledge Base query attachment selector must contain at least two parts")
|
||||
if any(not value for value in normalized):
|
||||
raise ValueError("Agent Knowledge Base query attachment selector must be non-empty")
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE",
|
||||
"KnowledgeRetrievalV2MetadataCondition",
|
||||
"KnowledgeRetrievalV2MetadataFilteringConditions",
|
||||
"KnowledgeRetrievalV2NodeData",
|
||||
]
|
||||
18
api/core/workflow/nodes/knowledge_retrieval_v2/exc.py
Normal file
18
api/core/workflow/nodes/knowledge_retrieval_v2/exc.py
Normal file
@@ -0,0 +1,18 @@
|
||||
class KnowledgeFSRetrievalV2NodeError(RuntimeError):
|
||||
"""Base deterministic KnowledgeFS v2 node failure."""
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalConfigurationError(KnowledgeFSRetrievalV2NodeError):
|
||||
"""The node configuration or selected query variable is invalid."""
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalBindingError(KnowledgeFSRetrievalV2NodeError):
|
||||
"""The app is not authorized for one selected KnowledgeFS Space."""
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalUnavailableError(KnowledgeFSRetrievalV2NodeError):
|
||||
"""KnowledgeFS could not provide a complete multi-space result."""
|
||||
|
||||
|
||||
class KnowledgeFSRetrievalContractError(KnowledgeFSRetrievalV2NodeError):
|
||||
"""KnowledgeFS returned a response outside the pinned product contract."""
|
||||
@@ -0,0 +1,960 @@
|
||||
"""KnowledgeFS-native evidence retrieval for Workflow and Chatflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal, Protocol, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, UserFrom
|
||||
from core.app.llm.model_access import build_dify_model_access
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError
|
||||
from core.knowledge_fs.errors import (
|
||||
QUERY_IMAGE_MAX_COUNT,
|
||||
QUERY_IMAGE_MAX_TOTAL_BYTES,
|
||||
KnowledgeFSAppAdmissionError,
|
||||
KnowledgeFSAppAuthorizationNotReadyError,
|
||||
KnowledgeFSAppBindingManagementError,
|
||||
KnowledgeFSAppChannelDisabledError,
|
||||
KnowledgeFSAppSpaceUnavailableError,
|
||||
KnowledgeFSOperationUnavailableError,
|
||||
KnowledgeFSProductRemoteError,
|
||||
KnowledgeFSProductRequestRejectedError,
|
||||
KnowledgeFSQueryImageError,
|
||||
KnowledgeFSWorkflowQueryImageReference,
|
||||
)
|
||||
from core.knowledge_fs.resource import (
|
||||
KnowledgeResourceRef,
|
||||
)
|
||||
from core.knowledge_fs.retrieval_contracts import (
|
||||
KnowledgeFSAppBindingPayload,
|
||||
KnowledgeFSMetadataFieldListResponse,
|
||||
KnowledgeFSMetadataFieldResponse,
|
||||
KnowledgeFSRetrievalCustomMetadataCondition,
|
||||
KnowledgeFSRetrievalCustomMetadataFilter,
|
||||
KnowledgeFSRetrievalMetadataFilters,
|
||||
KnowledgeFSRetrievalQueryImageReference,
|
||||
KnowledgeFSRetrievalTestItemResponse,
|
||||
KnowledgeFSRetrievalTestPayload,
|
||||
KnowledgeFSRetrievalTestResponse,
|
||||
)
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.rerank_entities import RerankResult
|
||||
from graphon.node_events import NodeRunResult
|
||||
from graphon.nodes.base.node import Node
|
||||
from graphon.variables import ArrayFileSegment, FileSegment, StringSegment
|
||||
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
|
||||
from graphon.variables.template_resolution import convert_template
|
||||
from models.knowledge_fs import KnowledgeFSAppSpaceJoinType
|
||||
|
||||
from .automatic_metadata_filter import (
|
||||
KnowledgeFSAutomaticMetadataFilterExtractor,
|
||||
KnowledgeFSAutomaticMetadataFilterOutcome,
|
||||
KnowledgeFSMetadataFilterExtractor,
|
||||
intersect_metadata_fields,
|
||||
to_custom_metadata_conditions,
|
||||
)
|
||||
from .entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE, KnowledgeRetrievalV2NodeData
|
||||
from .exc import (
|
||||
KnowledgeFSRetrievalBindingError,
|
||||
KnowledgeFSRetrievalConfigurationError,
|
||||
KnowledgeFSRetrievalContractError,
|
||||
KnowledgeFSRetrievalUnavailableError,
|
||||
KnowledgeFSRetrievalV2NodeError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_WORKFLOW_RERANK_CANDIDATES = 100
|
||||
WORKFLOW_RERANK_POOL_MULTIPLIER = 4
|
||||
METADATA_CATALOG_PAGE_SIZE = 100
|
||||
METADATA_CATALOG_MAX_PAGES = 10
|
||||
|
||||
|
||||
def _normalize_metadata_filter_scalar(value: object) -> str | int | float | None:
|
||||
if value is None or isinstance(value, (str, float)):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
class QueryImageIssuer(Protocol):
|
||||
def __call__(self, *, app_id: str, file: File, tenant_id: str) -> KnowledgeFSWorkflowQueryImageReference: ...
|
||||
|
||||
|
||||
class FailedRetrievalDispatcher(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
control_space_id: str,
|
||||
query: str,
|
||||
mode: Literal["fast", "deep", "research"],
|
||||
retrieval_trace_id: str,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class _RetrievalCapability(Protocol):
|
||||
def run_retrieval(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
caller_kind: KnowledgeFSAppSpaceJoinType,
|
||||
resource: KnowledgeResourceRef,
|
||||
payload: KnowledgeFSRetrievalTestPayload,
|
||||
) -> KnowledgeFSRetrievalTestResponse: ...
|
||||
|
||||
def list_metadata_fields(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
caller_kind: KnowledgeFSAppSpaceJoinType,
|
||||
resource: KnowledgeResourceRef,
|
||||
cursor: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> KnowledgeFSMetadataFieldListResponse: ...
|
||||
|
||||
|
||||
class _BindingCapability(Protocol):
|
||||
def upsert(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_account_id: str,
|
||||
control_space_id: str,
|
||||
payload: KnowledgeFSAppBindingPayload,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class _RerankModelManager(Protocol):
|
||||
def get_default_model_instance(self, tenant_id: str, model_type: ModelType) -> ModelInstance: ...
|
||||
|
||||
def get_model_instance(
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
model_type: ModelType,
|
||||
model: str,
|
||||
) -> ModelInstance: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _WorkflowRerankCandidate:
|
||||
control_space_id: str
|
||||
item: KnowledgeFSRetrievalTestItemResponse
|
||||
item_index: int
|
||||
space_index: int
|
||||
|
||||
|
||||
class KnowledgeRetrievalV2Node(Node[KnowledgeRetrievalV2NodeData]):
|
||||
node_type = KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_id: str,
|
||||
data: KnowledgeRetrievalV2NodeData,
|
||||
*,
|
||||
graph_init_params: GraphInitParams,
|
||||
graph_runtime_state: GraphRuntimeState,
|
||||
capability_service: _RetrievalCapability | None = None,
|
||||
binding_service: _BindingCapability | None = None,
|
||||
rerank_model_manager: _RerankModelManager | None = None,
|
||||
metadata_filter_extractor: KnowledgeFSMetadataFilterExtractor | None = None,
|
||||
max_concurrency: int = 4,
|
||||
query_image_issuer: QueryImageIssuer | None = None,
|
||||
failed_retrieval_dispatcher: FailedRetrievalDispatcher | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
node_id=node_id,
|
||||
data=data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
if max_concurrency < 1 or max_concurrency > 10:
|
||||
raise ValueError("Agent Knowledge Retrieval concurrency must be between 1 and 10")
|
||||
self._capability_service = capability_service
|
||||
self._binding_service = binding_service
|
||||
self._rerank_model_manager = rerank_model_manager
|
||||
self._metadata_filter_extractor = metadata_filter_extractor
|
||||
self._max_concurrency = max_concurrency
|
||||
self._query_image_issuer = query_image_issuer
|
||||
self._failed_retrieval_dispatcher = failed_retrieval_dispatcher
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
@override
|
||||
def _run(self) -> NodeRunResult:
|
||||
started_at = time.perf_counter()
|
||||
usage = LLMUsage.empty_usage()
|
||||
try:
|
||||
query = self._resolve_query()
|
||||
run_context = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
||||
query_images, query_image_inputs = self._resolve_query_images(run_context)
|
||||
if not query and not query_images:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Agent Knowledge Retrieval requires a query text or at least one query image"
|
||||
)
|
||||
self._ensure_draft_bindings(run_context)
|
||||
filters, automatic_outcome = self._resolved_retrieval_filters(run_context=run_context, query=query)
|
||||
if automatic_outcome is not None:
|
||||
usage = automatic_outcome.usage
|
||||
responses = self._retrieve_all_spaces(
|
||||
run_context=run_context,
|
||||
query=query,
|
||||
query_images=query_images,
|
||||
filters=filters,
|
||||
)
|
||||
result_items, rerank_metrics = self._merge_items(
|
||||
responses,
|
||||
query=query,
|
||||
tenant_id=run_context.tenant_id,
|
||||
)
|
||||
if not result_items and all(not response.items for _, response in responses):
|
||||
self._enqueue_failed_retrieval_captures(
|
||||
run_context=run_context,
|
||||
query=query,
|
||||
responses=responses,
|
||||
)
|
||||
metrics = self._aggregate_metrics(
|
||||
responses,
|
||||
rerank_metrics=rerank_metrics,
|
||||
started_at=started_at,
|
||||
automatic_outcome=automatic_outcome,
|
||||
)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs={"query": query, **({"query_images": query_image_inputs} if query_image_inputs else {})},
|
||||
process_data={"knowledge_fs": metrics},
|
||||
outputs={
|
||||
"result": ArrayObjectSegment(value=result_items),
|
||||
"metrics": ObjectSegment(value=metrics),
|
||||
},
|
||||
metadata=self._usage_metadata(usage),
|
||||
llm_usage=usage,
|
||||
)
|
||||
except KnowledgeFSRetrievalV2NodeError as exc:
|
||||
logger.warning("KnowledgeFS v2 retrieval failed: %s", exc)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
inputs={},
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
metadata=self._usage_metadata(usage),
|
||||
llm_usage=usage,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _usage_metadata(usage: LLMUsage) -> dict[WorkflowNodeExecutionMetadataKey, Any]:
|
||||
if usage.total_tokens <= 0:
|
||||
return {}
|
||||
return {
|
||||
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
|
||||
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
|
||||
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
|
||||
}
|
||||
|
||||
def _resolve_query(self) -> str:
|
||||
"""Return the bound query text, or an empty string when the node relies on query images."""
|
||||
|
||||
selector = self._node_data.query_variable_selector
|
||||
if not selector:
|
||||
return ""
|
||||
variable = self.graph_runtime_state.variable_pool.get(selector)
|
||||
if variable is None:
|
||||
return ""
|
||||
if not isinstance(variable, StringSegment):
|
||||
raise KnowledgeFSRetrievalConfigurationError("Agent Knowledge Base query variable must be a string")
|
||||
query = variable.value.strip()
|
||||
if len(query) > 16_000:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Agent Knowledge Base query variable must contain at most 16000 characters"
|
||||
)
|
||||
return query
|
||||
|
||||
def _resolve_query_images(
|
||||
self,
|
||||
run_context: DifyRunContext,
|
||||
) -> tuple[list[KnowledgeFSRetrievalQueryImageReference], list[dict[str, object]]]:
|
||||
selector = self._node_data.query_attachment_selector
|
||||
if not selector:
|
||||
return [], []
|
||||
variable = self.graph_runtime_state.variable_pool.get(selector)
|
||||
if isinstance(variable, FileSegment):
|
||||
files = [variable.value]
|
||||
elif isinstance(variable, ArrayFileSegment):
|
||||
files = list(variable.value)
|
||||
else:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Agent Knowledge Base query attachment variable must be a file or array of files"
|
||||
)
|
||||
if len(files) > QUERY_IMAGE_MAX_COUNT:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
f"Agent Knowledge Base query attachments must contain at most {QUERY_IMAGE_MAX_COUNT} images"
|
||||
)
|
||||
|
||||
references: list[KnowledgeFSRetrievalQueryImageReference] = []
|
||||
inputs: list[dict[str, object]] = []
|
||||
total_bytes = 0
|
||||
seen_ids: set[str] = set()
|
||||
try:
|
||||
for file in files:
|
||||
reference = self._issue_query_image_reference(
|
||||
app_id=run_context.app_id,
|
||||
file=file,
|
||||
tenant_id=run_context.tenant_id,
|
||||
)
|
||||
if reference.upload_file_id in seen_ids:
|
||||
raise KnowledgeFSQueryImageError(
|
||||
"QUERY_IMAGE_REFERENCE_DUPLICATE",
|
||||
"Workflow query images must not contain duplicate files",
|
||||
)
|
||||
seen_ids.add(reference.upload_file_id)
|
||||
total_bytes += reference.byte_size
|
||||
if total_bytes > QUERY_IMAGE_MAX_TOTAL_BYTES:
|
||||
raise KnowledgeFSQueryImageError(
|
||||
"QUERY_IMAGE_TOTAL_TOO_LARGE",
|
||||
f"Workflow query images exceed aggregate max bytes {QUERY_IMAGE_MAX_TOTAL_BYTES}",
|
||||
)
|
||||
references.append(
|
||||
KnowledgeFSRetrievalQueryImageReference(
|
||||
accessGrant=reference.access_grant,
|
||||
uploadFileId=reference.upload_file_id,
|
||||
)
|
||||
)
|
||||
inputs.append(
|
||||
{
|
||||
"filename": file.filename or reference.upload_file_id,
|
||||
"mime_type": reference.mime_type,
|
||||
"size": reference.byte_size,
|
||||
}
|
||||
)
|
||||
except KnowledgeFSQueryImageError as exc:
|
||||
raise KnowledgeFSRetrievalConfigurationError(str(exc)) from exc
|
||||
return references, inputs
|
||||
|
||||
def _issue_query_image_reference(
|
||||
self, *, app_id: str, file: File, tenant_id: str
|
||||
) -> KnowledgeFSWorkflowQueryImageReference:
|
||||
if self._query_image_issuer is None:
|
||||
raise KnowledgeFSRetrievalConfigurationError("Workflow query-image issuer is not configured")
|
||||
return self._query_image_issuer(app_id=app_id, file=file, tenant_id=tenant_id)
|
||||
|
||||
def _service(self) -> _RetrievalCapability:
|
||||
if self._capability_service is None:
|
||||
raise KnowledgeFSRetrievalConfigurationError("Agent Knowledge Retrieval capability is not configured")
|
||||
return self._capability_service
|
||||
|
||||
def _ensure_draft_bindings(self, run_context: DifyRunContext) -> None:
|
||||
if not run_context.invoke_from.runs_as_account() or run_context.user_from is not UserFrom.ACCOUNT:
|
||||
return
|
||||
binding_service = self._binding_service
|
||||
if binding_service is None:
|
||||
return
|
||||
try:
|
||||
for control_space_id in self._node_data.control_space_ids:
|
||||
binding_service.upsert(
|
||||
tenant_id=run_context.tenant_id,
|
||||
actor_account_id=run_context.user_id,
|
||||
control_space_id=control_space_id,
|
||||
payload=KnowledgeFSAppBindingPayload(
|
||||
app_id=run_context.app_id,
|
||||
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
|
||||
),
|
||||
)
|
||||
except (KnowledgeFSAppBindingManagementError, RuntimeError, ValueError) as exc:
|
||||
raise KnowledgeFSRetrievalBindingError(
|
||||
"Agent Knowledge Base draft binding could not be enabled for this workflow"
|
||||
) from exc
|
||||
|
||||
def _retrieve_all_spaces(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
query: str,
|
||||
query_images: Sequence[KnowledgeFSRetrievalQueryImageReference],
|
||||
filters: KnowledgeFSRetrievalMetadataFilters | None,
|
||||
) -> list[tuple[str, KnowledgeFSRetrievalTestResponse]]:
|
||||
service = self._service()
|
||||
payload = KnowledgeFSRetrievalTestPayload(
|
||||
query=query,
|
||||
query_id=uuid.uuid4(),
|
||||
queryImages=list(query_images),
|
||||
mode=self._node_data.mode,
|
||||
include_text=True,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def retrieve(control_space_id: str) -> KnowledgeFSRetrievalTestResponse:
|
||||
with self._map_space_errors(control_space_id, action="retrieval"):
|
||||
return service.run_retrieval(
|
||||
run_context=run_context,
|
||||
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
|
||||
resource=KnowledgeResourceRef(
|
||||
kind="knowledge_fs",
|
||||
control_space_id=control_space_id,
|
||||
),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
worker_count = min(self._max_concurrency, len(self._node_data.control_space_ids))
|
||||
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="knowledge-fs-retrieval") as executor:
|
||||
futures = [executor.submit(retrieve, space_id) for space_id in self._node_data.control_space_ids]
|
||||
return [
|
||||
(space_id, future.result())
|
||||
for space_id, future in zip(self._node_data.control_space_ids, futures, strict=True)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def _map_space_errors(control_space_id: str, *, action: str) -> Generator[None]:
|
||||
try:
|
||||
yield
|
||||
except KnowledgeFSAppChannelDisabledError as exc:
|
||||
raise KnowledgeFSRetrievalBindingError(
|
||||
"[knowledge_fs_workflow_access_disabled] "
|
||||
f"Workflow access is disabled for Agent Knowledge Base {control_space_id}; "
|
||||
"ask a workspace owner to enable the Workflow channel"
|
||||
) from exc
|
||||
except KnowledgeFSAppSpaceUnavailableError as exc:
|
||||
raise KnowledgeFSRetrievalBindingError(
|
||||
"[knowledge_fs_space_unavailable] "
|
||||
f"Agent Knowledge Base {control_space_id} is not ready for workflow {action}; "
|
||||
"select an active, provisioned Agent Knowledge Base"
|
||||
) from exc
|
||||
except KnowledgeFSAppAuthorizationNotReadyError as exc:
|
||||
raise KnowledgeFSRetrievalBindingError(
|
||||
"[knowledge_fs_authorization_not_ready] "
|
||||
f"Agent Knowledge Base {control_space_id} permissions are not ready; "
|
||||
"ask a workspace owner to finish Agent Knowledge Base permission setup"
|
||||
) from exc
|
||||
except KnowledgeFSAppAdmissionError as exc:
|
||||
raise KnowledgeFSRetrievalBindingError(
|
||||
f"[knowledge_fs_binding_not_enabled] Agent Knowledge Base {control_space_id} "
|
||||
"is not bound to this workflow"
|
||||
) from exc
|
||||
except ValidationError as exc:
|
||||
raise KnowledgeFSRetrievalContractError(
|
||||
f"Agent Knowledge Base {control_space_id} returned an invalid {action} response"
|
||||
) from exc
|
||||
except KnowledgeFSProductRequestRejectedError as exc:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
f"Agent Knowledge Base {control_space_id} rejected the {action} request"
|
||||
) from exc
|
||||
except (KnowledgeFSOperationUnavailableError, KnowledgeFSProductRemoteError) as exc:
|
||||
raise KnowledgeFSRetrievalUnavailableError(
|
||||
f"Agent Knowledge Base {control_space_id} is unavailable"
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise KnowledgeFSRetrievalUnavailableError(
|
||||
f"Agent Knowledge Base {control_space_id} {action} failed"
|
||||
) from exc
|
||||
|
||||
def _resolved_retrieval_filters(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
query: str,
|
||||
) -> tuple[KnowledgeFSRetrievalMetadataFilters | None, KnowledgeFSAutomaticMetadataFilterOutcome | None]:
|
||||
filters = self._node_data.metadata_filters.model_copy(deep=True) if self._node_data.metadata_filters else None
|
||||
mode = self._node_data.metadata_filtering_mode
|
||||
if mode == "automatic":
|
||||
if not query:
|
||||
# Condition extraction is text-only; an image-only query has nothing to extract from.
|
||||
return filters, KnowledgeFSAutomaticMetadataFilterOutcome(
|
||||
conditions=[],
|
||||
usage=LLMUsage.empty_usage(),
|
||||
applied=False,
|
||||
reason="empty_query",
|
||||
)
|
||||
outcome = self._resolve_automatic_metadata_filter(run_context=run_context, query=query)
|
||||
if not outcome.conditions:
|
||||
return filters, outcome
|
||||
configured = self._node_data.metadata_filtering_conditions
|
||||
filters = filters or KnowledgeFSRetrievalMetadataFilters()
|
||||
filters.custom_metadata = KnowledgeFSRetrievalCustomMetadataFilter(
|
||||
logical_operator=configured.logical_operator if configured else "or",
|
||||
conditions=outcome.conditions,
|
||||
)
|
||||
return filters, outcome
|
||||
if mode != "manual":
|
||||
return filters, None
|
||||
return self._resolve_manual_retrieval_filters(filters), None
|
||||
|
||||
def _resolve_automatic_metadata_filter(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
query: str,
|
||||
) -> KnowledgeFSAutomaticMetadataFilterOutcome:
|
||||
model_config = self._node_data.metadata_model_config
|
||||
if model_config is None:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Agent Knowledge Base automatic metadata filtering requires a metadata filtering model"
|
||||
)
|
||||
catalog = intersect_metadata_fields(
|
||||
[
|
||||
self._list_space_metadata_fields(run_context=run_context, control_space_id=control_space_id)
|
||||
for control_space_id in self._node_data.control_space_ids
|
||||
]
|
||||
)
|
||||
field_names = [item.name for item in catalog]
|
||||
if not catalog:
|
||||
return KnowledgeFSAutomaticMetadataFilterOutcome(
|
||||
conditions=[],
|
||||
usage=LLMUsage.empty_usage(),
|
||||
applied=False,
|
||||
reason="no_shared_metadata_fields",
|
||||
)
|
||||
try:
|
||||
extraction = self._metadata_filter_extractor_for(run_context).extract(
|
||||
run_context=run_context,
|
||||
model_config=model_config,
|
||||
query=query,
|
||||
field_names=field_names,
|
||||
)
|
||||
except KnowledgeFSRetrievalV2NodeError:
|
||||
raise
|
||||
except Exception:
|
||||
# Match the legacy node: an LLM or parsing failure degrades to unfiltered retrieval
|
||||
# instead of failing the whole node.
|
||||
logger.warning("KnowledgeFS automatic metadata filter extraction failed", exc_info=True)
|
||||
return KnowledgeFSAutomaticMetadataFilterOutcome(
|
||||
conditions=[],
|
||||
usage=LLMUsage.empty_usage(),
|
||||
applied=False,
|
||||
reason="extraction_failed",
|
||||
field_names=field_names,
|
||||
model=model_config.name,
|
||||
provider=model_config.provider,
|
||||
)
|
||||
conditions = to_custom_metadata_conditions(extraction.metadata_map, catalog)
|
||||
return KnowledgeFSAutomaticMetadataFilterOutcome(
|
||||
conditions=conditions,
|
||||
usage=extraction.usage,
|
||||
applied=bool(conditions),
|
||||
reason=None if conditions else "no_conditions_extracted",
|
||||
field_names=field_names,
|
||||
extracted_count=len(extraction.metadata_map),
|
||||
model=extraction.model,
|
||||
provider=extraction.provider,
|
||||
)
|
||||
|
||||
def _metadata_filter_extractor_for(self, run_context: DifyRunContext) -> KnowledgeFSMetadataFilterExtractor:
|
||||
if self._metadata_filter_extractor is not None:
|
||||
return self._metadata_filter_extractor
|
||||
credentials_provider, model_factory = build_dify_model_access(run_context)
|
||||
return KnowledgeFSAutomaticMetadataFilterExtractor(
|
||||
credentials_provider=credentials_provider,
|
||||
model_factory=model_factory,
|
||||
)
|
||||
|
||||
def _list_space_metadata_fields(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
control_space_id: str,
|
||||
) -> list[KnowledgeFSMetadataFieldResponse]:
|
||||
service = self._service()
|
||||
resource = KnowledgeResourceRef(kind="knowledge_fs", control_space_id=control_space_id)
|
||||
fields: list[KnowledgeFSMetadataFieldResponse] = []
|
||||
cursor: str | None = None
|
||||
for _ in range(METADATA_CATALOG_MAX_PAGES):
|
||||
with self._map_space_errors(control_space_id, action="metadata catalog"):
|
||||
page = service.list_metadata_fields(
|
||||
run_context=run_context,
|
||||
caller_kind=KnowledgeFSAppSpaceJoinType.WORKFLOW,
|
||||
resource=resource,
|
||||
cursor=cursor,
|
||||
limit=METADATA_CATALOG_PAGE_SIZE,
|
||||
)
|
||||
fields.extend(page.data)
|
||||
cursor = page.next_cursor
|
||||
if not cursor:
|
||||
return fields
|
||||
raise KnowledgeFSRetrievalContractError(
|
||||
f"Agent Knowledge Base {control_space_id} metadata catalog exceeds the supported page count"
|
||||
)
|
||||
|
||||
def _resolve_manual_retrieval_filters(
|
||||
self,
|
||||
filters: KnowledgeFSRetrievalMetadataFilters | None,
|
||||
) -> KnowledgeFSRetrievalMetadataFilters | None:
|
||||
configured = self._node_data.metadata_filtering_conditions
|
||||
if not configured or not configured.conditions:
|
||||
return filters
|
||||
|
||||
resolved_conditions: list[KnowledgeFSRetrievalCustomMetadataCondition] = []
|
||||
for condition in configured.conditions:
|
||||
value = condition.value
|
||||
if isinstance(value, str):
|
||||
segment_group = convert_template(self.graph_runtime_state.variable_pool, value)
|
||||
if len(segment_group.value) == 1:
|
||||
value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object())
|
||||
else:
|
||||
value = segment_group.text
|
||||
if value is None and condition.comparison_operator not in {"empty", "not empty"}:
|
||||
continue
|
||||
resolved_conditions.append(
|
||||
KnowledgeFSRetrievalCustomMetadataCondition(
|
||||
name=condition.name,
|
||||
field_type=condition.metadata_type,
|
||||
comparison_operator=condition.comparison_operator,
|
||||
value=value,
|
||||
)
|
||||
)
|
||||
|
||||
if not resolved_conditions:
|
||||
return filters
|
||||
filters = filters or KnowledgeFSRetrievalMetadataFilters()
|
||||
filters.custom_metadata = KnowledgeFSRetrievalCustomMetadataFilter(
|
||||
logical_operator=configured.logical_operator,
|
||||
conditions=resolved_conditions,
|
||||
)
|
||||
return filters
|
||||
|
||||
def _merge_items(
|
||||
self,
|
||||
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
|
||||
*,
|
||||
query: str,
|
||||
tenant_id: str,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
candidates = self._balanced_candidate_pool(responses)
|
||||
if not candidates:
|
||||
return [], {
|
||||
"applied": False,
|
||||
"candidate_count": 0,
|
||||
"duration_ms": 0.0,
|
||||
"output_count": 0,
|
||||
"score_threshold": self._node_data.score_threshold,
|
||||
"source": "custom" if self._node_data.reranking_model else "system-default",
|
||||
"top_k": self._node_data.top_n,
|
||||
}
|
||||
|
||||
if not query:
|
||||
# Rerankers score documents against text. An image-only query keeps each space's own
|
||||
# profile-defined scores and merges the balanced pool on those instead.
|
||||
return self._merge_without_rerank(candidates)
|
||||
|
||||
model_manager = self._rerank_model_manager or ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
selection = self._node_data.reranking_model
|
||||
try:
|
||||
if selection is None:
|
||||
model_instance = model_manager.get_default_model_instance(tenant_id, ModelType.RERANK)
|
||||
source = "system-default"
|
||||
else:
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
provider=selection.provider,
|
||||
model_type=ModelType.RERANK,
|
||||
model=selection.model,
|
||||
)
|
||||
source = "custom"
|
||||
except (ModelCurrentlyNotSupportError, ProviderTokenNotInitError, ValueError) as exc:
|
||||
raise KnowledgeFSRetrievalConfigurationError(
|
||||
"Workflow rerank model is not configured or is unavailable"
|
||||
) from exc
|
||||
|
||||
documents = [self._rerank_text(candidate.item) for candidate in candidates]
|
||||
rerank_started_at = time.perf_counter()
|
||||
try:
|
||||
result = model_instance.invoke_rerank(
|
||||
query=query,
|
||||
docs=documents,
|
||||
score_threshold=self._node_data.score_threshold,
|
||||
top_n=min(self._node_data.top_n, len(candidates)),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise KnowledgeFSRetrievalUnavailableError("Workflow rerank model invocation failed") from exc
|
||||
duration_ms = round(max(0.0, (time.perf_counter() - rerank_started_at) * 1_000), 3)
|
||||
ranked = self._validate_rerank_result(result=result, candidates=candidates)
|
||||
threshold = self._node_data.score_threshold
|
||||
if threshold is not None:
|
||||
ranked = [row for row in ranked if row[0] >= threshold]
|
||||
ranked.sort(
|
||||
key=lambda row: (
|
||||
-row[0],
|
||||
row[1].space_index,
|
||||
row[1].item_index,
|
||||
row[1].item.node_id,
|
||||
)
|
||||
)
|
||||
ranked = ranked[: self._node_data.top_n]
|
||||
output = [
|
||||
self._output_item(
|
||||
control_space_id=candidate.control_space_id,
|
||||
item=candidate.item,
|
||||
score=score,
|
||||
)
|
||||
for score, candidate in ranked
|
||||
]
|
||||
return output, {
|
||||
"applied": True,
|
||||
"candidate_count": len(candidates),
|
||||
"duration_ms": duration_ms,
|
||||
"model": model_instance.model_name,
|
||||
"output_count": len(output),
|
||||
"provider": model_instance.provider,
|
||||
"score_threshold": threshold,
|
||||
"source": source,
|
||||
"top_k": self._node_data.top_n,
|
||||
}
|
||||
|
||||
def _merge_without_rerank(
|
||||
self,
|
||||
candidates: Sequence[_WorkflowRerankCandidate],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
threshold = self._node_data.score_threshold
|
||||
ranked = [
|
||||
(candidate.item.score, candidate)
|
||||
for candidate in candidates
|
||||
if threshold is None or candidate.item.score >= threshold
|
||||
]
|
||||
ranked.sort(
|
||||
key=lambda row: (
|
||||
-row[0],
|
||||
row[1].space_index,
|
||||
row[1].item_index,
|
||||
row[1].item.node_id,
|
||||
)
|
||||
)
|
||||
ranked = ranked[: self._node_data.top_n]
|
||||
output = [
|
||||
self._output_item(
|
||||
control_space_id=candidate.control_space_id,
|
||||
item=candidate.item,
|
||||
score=score,
|
||||
)
|
||||
for score, candidate in ranked
|
||||
]
|
||||
return output, {
|
||||
"applied": False,
|
||||
"candidate_count": len(candidates),
|
||||
"duration_ms": 0.0,
|
||||
"output_count": len(output),
|
||||
"reason": "image_only_query",
|
||||
"score_threshold": threshold,
|
||||
"source": "custom" if self._node_data.reranking_model else "system-default",
|
||||
"top_k": self._node_data.top_n,
|
||||
}
|
||||
|
||||
def _balanced_candidate_pool(
|
||||
self,
|
||||
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
|
||||
) -> list[_WorkflowRerankCandidate]:
|
||||
limit = min(
|
||||
MAX_WORKFLOW_RERANK_CANDIDATES,
|
||||
max(self._node_data.top_n, self._node_data.top_n * WORKFLOW_RERANK_POOL_MULTIPLIER),
|
||||
)
|
||||
candidates: list[_WorkflowRerankCandidate] = []
|
||||
max_items = max((len(response.items) for _, response in responses), default=0)
|
||||
for item_index in range(max_items):
|
||||
for space_index, (control_space_id, response) in enumerate(responses):
|
||||
if item_index >= len(response.items):
|
||||
continue
|
||||
candidates.append(
|
||||
_WorkflowRerankCandidate(
|
||||
control_space_id=control_space_id,
|
||||
item=response.items[item_index],
|
||||
item_index=item_index,
|
||||
space_index=space_index,
|
||||
)
|
||||
)
|
||||
if len(candidates) >= limit:
|
||||
return candidates
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _rerank_text(item: KnowledgeFSRetrievalTestItemResponse) -> str:
|
||||
text = (item.text or "").strip()
|
||||
if text:
|
||||
return text
|
||||
section_path = " / ".join(part.strip() for part in item.citation.section_path if part.strip())
|
||||
return section_path or item.node_id
|
||||
|
||||
@staticmethod
|
||||
def _validate_rerank_result(
|
||||
*,
|
||||
result: RerankResult,
|
||||
candidates: Sequence[_WorkflowRerankCandidate],
|
||||
) -> list[tuple[float, _WorkflowRerankCandidate]]:
|
||||
ranked: list[tuple[float, _WorkflowRerankCandidate]] = []
|
||||
seen_indices: set[int] = set()
|
||||
for document in result.docs:
|
||||
if document.index in seen_indices or document.index < 0 or document.index >= len(candidates):
|
||||
raise KnowledgeFSRetrievalContractError("Workflow rerank model returned invalid document indices")
|
||||
if not math.isfinite(document.score):
|
||||
raise KnowledgeFSRetrievalContractError("Workflow rerank model returned a non-finite score")
|
||||
seen_indices.add(document.index)
|
||||
ranked.append((document.score, candidates[document.index]))
|
||||
return ranked
|
||||
|
||||
def _enqueue_failed_retrieval_captures(
|
||||
self,
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
query: str,
|
||||
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
|
||||
) -> None:
|
||||
"""Best-effort quality capture after every selected space returned no evidence."""
|
||||
|
||||
if not query or self._failed_retrieval_dispatcher is None:
|
||||
# Failed-query triage is text-based; an image-only miss has no query to capture.
|
||||
return
|
||||
for control_space_id, response in responses:
|
||||
try:
|
||||
self._failed_retrieval_dispatcher(
|
||||
tenant_id=run_context.tenant_id,
|
||||
app_id=run_context.app_id,
|
||||
control_space_id=control_space_id,
|
||||
query=query,
|
||||
mode=response.mode,
|
||||
# Prefer the recorded history trace so the capture attaches to it instead of
|
||||
# creating a second record; older gateways only return the transport trace.
|
||||
retrieval_trace_id=response.answer_trace_id or response.trace_id,
|
||||
)
|
||||
except Exception:
|
||||
# The helper owns broker failures in production. Keep a second boundary here so a
|
||||
# custom/instrumented dispatcher can never turn a successful empty retrieval into
|
||||
# a failed Workflow node.
|
||||
logger.exception(
|
||||
"KnowledgeFS empty-retrieval quality capture dispatch failed",
|
||||
extra={
|
||||
"app_id": run_context.app_id,
|
||||
"control_space_id": control_space_id,
|
||||
"retrieval_trace_id": response.trace_id,
|
||||
"tenant_id": run_context.tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _output_item(
|
||||
*,
|
||||
control_space_id: str,
|
||||
item: KnowledgeFSRetrievalTestItemResponse,
|
||||
score: float,
|
||||
) -> dict[str, Any]:
|
||||
citation = item.citation
|
||||
title = citation.document_title or citation.document_asset_id
|
||||
content = item.text or ""
|
||||
citation_metadata = {
|
||||
"artifact_hash": citation.artifact_hash,
|
||||
"document_id": citation.logical_document_id or citation.document_asset_id,
|
||||
"document_asset_id": citation.document_asset_id,
|
||||
"document_version": citation.document_version,
|
||||
"logical_document_id": citation.logical_document_id,
|
||||
"logical_document_revision": citation.logical_document_revision,
|
||||
"section_path": list(citation.section_path),
|
||||
**({"page_number": citation.page_number} if citation.page_number is not None else {}),
|
||||
**({"start_offset": citation.start_offset} if citation.start_offset is not None else {}),
|
||||
**({"end_offset": citation.end_offset} if citation.end_offset is not None else {}),
|
||||
}
|
||||
return {
|
||||
"content": content,
|
||||
"title": title,
|
||||
"metadata": {
|
||||
"citation": citation_metadata,
|
||||
"node_id": item.node_id,
|
||||
"projection_ids": list(item.projection_ids),
|
||||
"score": score,
|
||||
"space_score": item.score,
|
||||
"sources": list(item.sources),
|
||||
"space_id": control_space_id,
|
||||
# LLM context extraction currently recognizes the legacy Knowledge
|
||||
# metadata contract. Preserve the native KnowledgeFS citation above
|
||||
# while providing the compatibility fields needed to emit chat
|
||||
# retriever resources.
|
||||
"_source": "knowledge",
|
||||
"dataset_id": control_space_id,
|
||||
"dataset_name": citation.knowledge_space_name or control_space_id,
|
||||
"document_id": citation.logical_document_id or citation.document_asset_id,
|
||||
"document_asset_id": citation.document_asset_id,
|
||||
"document_name": title,
|
||||
"document_revision": citation.logical_document_revision,
|
||||
"document_version": citation.document_version,
|
||||
"data_source_type": "knowledge_fs",
|
||||
"segment_id": item.node_id,
|
||||
"retriever_from": "workflow",
|
||||
"segment_word_count": len(content),
|
||||
"segment_index_node_hash": citation.artifact_hash,
|
||||
"page": citation.page_number,
|
||||
},
|
||||
}
|
||||
|
||||
def _aggregate_metrics(
|
||||
self,
|
||||
responses: Sequence[tuple[str, KnowledgeFSRetrievalTestResponse]],
|
||||
*,
|
||||
rerank_metrics: Mapping[str, Any],
|
||||
started_at: float,
|
||||
automatic_outcome: KnowledgeFSAutomaticMetadataFilterOutcome | None = None,
|
||||
) -> dict[str, Any]:
|
||||
effective_modes = list(dict.fromkeys(response.mode for _, response in responses))
|
||||
degradation_flags = list(
|
||||
dict.fromkeys(flag for _, response in responses for flag in response.metrics.degradation_flags)
|
||||
)
|
||||
per_space = [
|
||||
{
|
||||
"candidate_count": len(response.items),
|
||||
"control_space_id": control_space_id,
|
||||
"degradation_flags": list(response.metrics.degradation_flags),
|
||||
"mode": response.mode,
|
||||
"total_ms": response.metrics.total_ms,
|
||||
"trace_id": response.trace_id,
|
||||
}
|
||||
for control_space_id, response in responses
|
||||
]
|
||||
metrics: dict[str, Any] = {
|
||||
"candidate_counts": {control_space_id: len(response.items) for control_space_id, response in responses},
|
||||
"degradation_flags": degradation_flags,
|
||||
"effective_modes": effective_modes,
|
||||
"mode": effective_modes[0] if len(effective_modes) == 1 else "mixed",
|
||||
"per_space": per_space,
|
||||
"requested_mode": self._node_data.mode or "space-default",
|
||||
"total_ms": round(max(0.0, (time.perf_counter() - started_at) * 1_000), 3),
|
||||
"workflow_rerank": dict(rerank_metrics),
|
||||
}
|
||||
if automatic_outcome is not None:
|
||||
metrics["metadata_filtering"] = automatic_outcome.as_metrics()
|
||||
return metrics
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
graph_config: Mapping[str, Any],
|
||||
node_id: str,
|
||||
node_data: KnowledgeRetrievalV2NodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
_ = graph_config
|
||||
mapping: dict[str, Sequence[str]] = {}
|
||||
if node_data.query_variable_selector:
|
||||
mapping[f"{node_id}.query"] = node_data.query_variable_selector
|
||||
if node_data.query_attachment_selector:
|
||||
mapping[f"{node_id}.queryAttachment"] = node_data.query_attachment_selector
|
||||
return mapping
|
||||
|
||||
|
||||
__all__ = ["KnowledgeRetrievalV2Node"]
|
||||
70
api/core/workflow/nodes/knowledge_retrieval_v2/validation.py
Normal file
70
api/core/workflow/nodes/knowledge_retrieval_v2/validation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
|
||||
|
||||
from .entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE, KnowledgeRetrievalV2NodeData
|
||||
|
||||
|
||||
def collect_control_space_ids(graph: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
nodes = graph.get("nodes", [])
|
||||
if not isinstance(nodes, list):
|
||||
return ()
|
||||
ordered_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
data = node.get("data")
|
||||
if not isinstance(data, Mapping) or data.get("type") != KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE:
|
||||
continue
|
||||
node_data = KnowledgeRetrievalV2NodeData.model_validate(data)
|
||||
for control_space_id in node_data.control_space_ids:
|
||||
if control_space_id not in seen:
|
||||
seen.add(control_space_id)
|
||||
ordered_ids.append(control_space_id)
|
||||
return tuple(ordered_ids)
|
||||
|
||||
|
||||
def missing_control_space_ids(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
graph: Mapping[str, Any],
|
||||
) -> tuple[str, ...]:
|
||||
requested = collect_control_space_ids(graph)
|
||||
if not requested:
|
||||
return ()
|
||||
existing = set(
|
||||
session.scalars(
|
||||
sa.select(KnowledgeFSControlSpace.id).where(
|
||||
KnowledgeFSControlSpace.tenant_id == tenant_id,
|
||||
KnowledgeFSControlSpace.id.in_(requested),
|
||||
KnowledgeFSControlSpace.state == KnowledgeFSControlSpaceState.ACTIVE,
|
||||
)
|
||||
)
|
||||
)
|
||||
return tuple(control_space_id for control_space_id in requested if control_space_id not in existing)
|
||||
|
||||
|
||||
def validate_control_space_references(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
graph: Mapping[str, Any],
|
||||
) -> None:
|
||||
missing = missing_control_space_ids(session=session, tenant_id=tenant_id, graph=graph)
|
||||
if missing:
|
||||
raise ValueError("Agent Knowledge Bases are missing or inactive in this workspace: " + ", ".join(missing))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"collect_control_space_ids",
|
||||
"missing_control_space_ids",
|
||||
"validate_control_space_references",
|
||||
]
|
||||
1
api/dev/__init__.py
Normal file
1
api/dev/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Development tooling for Dify API contracts and checks."""
|
||||
362
api/dev/check_knowledge_fs_coverage.py
Normal file
362
api/dev/check_knowledge_fs_coverage.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Enforce focused KnowledgeFS coverage without hiding integration-critical glue.
|
||||
|
||||
The primary threshold aggregates statement and branch coverage for every Dify
|
||||
module owned by the KnowledgeFS integration. Large pre-existing Dify modules
|
||||
that only contain narrow integration hooks are checked with changed-line
|
||||
coverage instead, so unrelated legacy code cannot dilute or inflate the gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, cast
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
|
||||
NON_CORE_COVERAGE_ALLOWLIST = frozenset(
|
||||
{
|
||||
"api/dev/check_knowledge_fs_coverage.py",
|
||||
"api/dev/generate_knowledge_fs_contract.py",
|
||||
"api/dev/knowledge_fs_product_contract.py",
|
||||
"api/migrations/versions/2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py",
|
||||
"api/migrations/versions/2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py",
|
||||
"api/migrations/versions/2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py",
|
||||
"api/migrations/versions/2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py",
|
||||
"api/migrations/versions/2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py",
|
||||
"api/migrations/versions/2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py",
|
||||
"api/migrations/versions/2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py",
|
||||
"api/migrations/versions/2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py",
|
||||
"api/migrations/versions/2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py",
|
||||
"api/migrations/versions/2026_08_18_1200-e6b4a2c9d731_limit_active_knowledge_fs_upgrades.py",
|
||||
"api/migrations/versions/2026_08_20_1200-c4d8e2f7a1b6_merge_agent_drive_and_knowledge_fs_heads.py",
|
||||
"api/migrations/versions/2026_09_17_1200-f7c8d9e0a1b2_merge_knowledge_fs_and_email_heads.py",
|
||||
"api/migrations/versions/2026_09_22_1200-a9c3e7f1b620_merge_knowledge_fs_and_sandbox_usage_heads.py",
|
||||
}
|
||||
)
|
||||
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoverageSummary(TypedDict):
|
||||
"""Coverage.py counts required by the aggregate gate."""
|
||||
|
||||
covered_lines: int
|
||||
num_statements: int
|
||||
covered_branches: int
|
||||
num_branches: int
|
||||
|
||||
|
||||
class CoverageFile(TypedDict):
|
||||
"""Per-file coverage data emitted by ``coverage json``."""
|
||||
|
||||
executed_lines: list[int]
|
||||
missing_lines: list[int]
|
||||
summary: CoverageSummary
|
||||
|
||||
|
||||
class CoverageReport(TypedDict):
|
||||
"""Relevant top-level shape of a coverage.py JSON report."""
|
||||
|
||||
files: dict[str, CoverageFile]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverageTotals:
|
||||
"""Covered and measurable units for one gate surface."""
|
||||
|
||||
covered: int
|
||||
total: int
|
||||
|
||||
@property
|
||||
def percent(self) -> float:
|
||||
return 100.0 if self.total == 0 else self.covered * 100 / self.total
|
||||
|
||||
|
||||
class CoverageGateError(RuntimeError):
|
||||
"""Raised when coverage input is incomplete or below its threshold."""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Validate focused module coverage and changed integration glue."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--coverage-json", type=Path, required=True)
|
||||
parser.add_argument("--glue-manifest", type=Path, required=True)
|
||||
parser.add_argument("--workspace-root", type=Path, default=WORKSPACE_ROOT)
|
||||
parser.add_argument("--base", default="")
|
||||
parser.add_argument("--minimum", type=float, default=90.0)
|
||||
parser.add_argument("--glue-minimum", type=float, default=90.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = args.workspace_root.resolve()
|
||||
report = load_coverage_report(args.coverage_json)
|
||||
core_totals = validate_core_coverage(report, workspace_root=workspace_root, minimum=args.minimum)
|
||||
base = resolve_diff_base(workspace_root, args.base)
|
||||
glue_paths = load_glue_coverage_paths(args.glue_manifest, workspace_root=workspace_root)
|
||||
changed_lines = collect_changed_glue_lines(workspace_root, base, glue_paths=glue_paths)
|
||||
glue_totals = validate_changed_glue_coverage(
|
||||
report,
|
||||
changed_lines=changed_lines,
|
||||
minimum=args.glue_minimum,
|
||||
)
|
||||
logger.info(
|
||||
"Dify KnowledgeFS coverage passed: core lines+branches %.2f%% (%d/%d); changed glue lines %.2f%% (%d/%d)",
|
||||
core_totals.percent,
|
||||
core_totals.covered,
|
||||
core_totals.total,
|
||||
glue_totals.percent,
|
||||
glue_totals.covered,
|
||||
glue_totals.total,
|
||||
)
|
||||
|
||||
|
||||
def load_coverage_report(path: Path) -> CoverageReport:
|
||||
"""Load the detailed JSON report used by both coverage checks."""
|
||||
if not path.is_file():
|
||||
raise CoverageGateError(f"coverage JSON does not exist: {path}")
|
||||
document = json.loads(path.read_text())
|
||||
if not isinstance(document, dict) or not isinstance(document.get("files"), dict):
|
||||
raise CoverageGateError(f"coverage JSON has no files object: {path}")
|
||||
return cast(CoverageReport, document)
|
||||
|
||||
|
||||
def load_glue_coverage_paths(path: Path, *, workspace_root: Path) -> tuple[str, ...]:
|
||||
"""Load the workflow's authoritative NUL-delimited integration touchpoints."""
|
||||
if not path.is_file():
|
||||
raise CoverageGateError(f"KnowledgeFS glue manifest does not exist: {path}")
|
||||
try:
|
||||
paths = tuple(item.decode() for item in path.read_bytes().split(b"\0") if item)
|
||||
except UnicodeDecodeError as error:
|
||||
raise CoverageGateError(f"KnowledgeFS glue manifest is not UTF-8: {path}") from error
|
||||
if not paths:
|
||||
raise CoverageGateError("Dify KnowledgeFS glue coverage target set is empty")
|
||||
if len(paths) != len(set(paths)):
|
||||
raise CoverageGateError("Dify KnowledgeFS glue coverage manifest contains duplicate paths")
|
||||
invalid_paths = [
|
||||
candidate
|
||||
for candidate in paths
|
||||
if not candidate.startswith("api/")
|
||||
or not candidate.endswith(".py")
|
||||
or not (workspace_root / candidate).is_file()
|
||||
]
|
||||
if invalid_paths:
|
||||
raise CoverageGateError(f"Dify KnowledgeFS glue coverage paths are invalid: {', '.join(invalid_paths)}")
|
||||
return paths
|
||||
|
||||
|
||||
def is_core_coverage_path(path: str) -> bool:
|
||||
"""Return whether a repository-relative path belongs to the focused aggregate."""
|
||||
if not path.endswith(".py"):
|
||||
return False
|
||||
if path in {
|
||||
"api/commands/knowledge_fs.py",
|
||||
"api/configs/extra/knowledge_fs_config.py",
|
||||
"api/controllers/openapi/knowledge_fs.py",
|
||||
"api/events/event_handlers/sync_knowledge_fs_bindings_when_app_published_workflow_updated.py",
|
||||
"api/extensions/ext_knowledge_fs_observability.py",
|
||||
"api/services/dataset_knowledge_fs_upgrade_file_lease.py",
|
||||
"api/services/dataset_knowledge_fs_upgrade_service.py",
|
||||
"api/services/knowledge_fs_capability.py",
|
||||
}:
|
||||
return True
|
||||
if path.startswith(
|
||||
(
|
||||
"api/controllers/console/knowledge_fs/",
|
||||
"api/controllers/inner_api/knowledge_fs/",
|
||||
"api/controllers/service_api/knowledge_fs/",
|
||||
"api/core/knowledge_fs/",
|
||||
"api/core/tools/builtin_tool/providers/knowledge_fs/",
|
||||
"api/services/knowledge_fs/",
|
||||
)
|
||||
):
|
||||
return True
|
||||
filename = path.rsplit("/", maxsplit=1)[-1]
|
||||
return (
|
||||
path.startswith("api/models/")
|
||||
and filename.startswith("knowledge_fs")
|
||||
or path.startswith("api/repositories/")
|
||||
and "knowledge_fs" in filename
|
||||
or path.startswith("api/tasks/")
|
||||
and "knowledge_fs" in filename
|
||||
)
|
||||
|
||||
|
||||
def discover_core_coverage_paths(workspace_root: Path) -> tuple[str, ...]:
|
||||
"""Classify every KnowledgeFS-named production file or fail closed."""
|
||||
named_paths = discover_knowledge_fs_production_paths(workspace_root)
|
||||
core_paths = {path for path in named_paths if is_core_coverage_path(path)}
|
||||
unclassified_paths = set(named_paths) - core_paths - NON_CORE_COVERAGE_ALLOWLIST
|
||||
if unclassified_paths:
|
||||
raise CoverageGateError(
|
||||
"unclassified Dify KnowledgeFS production files must join the core coverage scope or explicit allowlist: "
|
||||
+ ", ".join(sorted(unclassified_paths))
|
||||
)
|
||||
if not core_paths:
|
||||
raise CoverageGateError("Dify KnowledgeFS core coverage target set is empty")
|
||||
return tuple(sorted(core_paths))
|
||||
|
||||
|
||||
def discover_knowledge_fs_production_paths(workspace_root: Path) -> tuple[str, ...]:
|
||||
"""Mirror the workflow's dynamic KnowledgeFS filename discovery."""
|
||||
api_root = workspace_root / "api"
|
||||
if not api_root.is_dir():
|
||||
raise CoverageGateError(f"Dify API directory does not exist: {api_root}")
|
||||
paths: set[str] = set()
|
||||
for directory, child_directories, filenames in os.walk(api_root):
|
||||
current_directory = Path(directory)
|
||||
if current_directory == api_root:
|
||||
child_directories[:] = [name for name in child_directories if name not in {".venv", "storage", "tests"}]
|
||||
child_directories[:] = [name for name in child_directories if name != "__pycache__"]
|
||||
for filename in filenames:
|
||||
path = (current_directory / filename).relative_to(workspace_root).as_posix()
|
||||
if filename.endswith(".py") and "knowledge_fs" in path:
|
||||
paths.add(path)
|
||||
if not paths:
|
||||
raise CoverageGateError("Dify KnowledgeFS production target set is empty")
|
||||
return tuple(sorted(paths))
|
||||
|
||||
|
||||
def validate_core_coverage(
|
||||
report: CoverageReport,
|
||||
*,
|
||||
workspace_root: Path,
|
||||
minimum: float,
|
||||
) -> CoverageTotals:
|
||||
"""Require the exact combined line-and-branch percentage for all core files."""
|
||||
paths = discover_core_coverage_paths(workspace_root)
|
||||
missing_paths = [path for path in paths if path not in report["files"]]
|
||||
if missing_paths:
|
||||
raise CoverageGateError(f"coverage report is missing core files: {', '.join(missing_paths)}")
|
||||
|
||||
covered = 0
|
||||
total = 0
|
||||
for path in paths:
|
||||
summary = report["files"][path]["summary"]
|
||||
covered += summary["covered_lines"] + summary["covered_branches"]
|
||||
total += summary["num_statements"] + summary["num_branches"]
|
||||
if total == 0:
|
||||
raise CoverageGateError("Dify KnowledgeFS core coverage has no measurable statements or branches")
|
||||
|
||||
totals = CoverageTotals(covered=covered, total=total)
|
||||
_require_minimum(totals, minimum=minimum, label="Dify KnowledgeFS core line-and-branch coverage")
|
||||
return totals
|
||||
|
||||
|
||||
def resolve_diff_base(workspace_root: Path, preferred: str) -> str:
|
||||
"""Resolve an explicit event base, falling back to the previous commit for manual runs."""
|
||||
base = preferred.strip()
|
||||
if not base or set(base) == {"0"}:
|
||||
base = "HEAD^"
|
||||
result = subprocess.run(
|
||||
["git", "cat-file", "-e", f"{base}^{{commit}}"],
|
||||
cwd=workspace_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "commit is unavailable"
|
||||
raise CoverageGateError(f"cannot resolve coverage diff base {base}: {detail}")
|
||||
return base
|
||||
|
||||
|
||||
def collect_changed_glue_lines(
|
||||
workspace_root: Path,
|
||||
base: str,
|
||||
*,
|
||||
glue_paths: tuple[str, ...],
|
||||
) -> dict[str, set[int]]:
|
||||
"""Return added line numbers in the narrow Dify modules touched by this integration."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--unified=0",
|
||||
base,
|
||||
"--",
|
||||
*glue_paths,
|
||||
],
|
||||
cwd=workspace_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "git diff failed"
|
||||
raise CoverageGateError(f"cannot collect KnowledgeFS glue diff from {base}: {detail}")
|
||||
return parse_added_lines(result.stdout)
|
||||
|
||||
|
||||
def parse_added_lines(diff: str) -> dict[str, set[int]]:
|
||||
"""Parse repository paths and added-side line numbers from a zero-context Git diff."""
|
||||
changed_lines: dict[str, set[int]] = {}
|
||||
current_path: str | None = None
|
||||
current_line: int | None = None
|
||||
for raw_line in diff.splitlines():
|
||||
if raw_line.startswith("diff --git "):
|
||||
current_line = None
|
||||
continue
|
||||
if raw_line.startswith("+++ "):
|
||||
candidate = raw_line[4:]
|
||||
current_path = candidate[2:] if candidate.startswith("b/") else None
|
||||
if current_path is not None:
|
||||
changed_lines.setdefault(current_path, set())
|
||||
current_line = None
|
||||
continue
|
||||
if raw_line.startswith("@@ "):
|
||||
match = HUNK_HEADER.match(raw_line)
|
||||
current_line = int(match.group(1)) if match is not None else None
|
||||
continue
|
||||
if current_path is None or current_line is None:
|
||||
continue
|
||||
if raw_line.startswith("+"):
|
||||
changed_lines[current_path].add(current_line)
|
||||
current_line += 1
|
||||
elif raw_line.startswith("-") or raw_line.startswith("\\"):
|
||||
continue
|
||||
else:
|
||||
current_line += 1
|
||||
return changed_lines
|
||||
|
||||
|
||||
def validate_changed_glue_coverage(
|
||||
report: CoverageReport,
|
||||
*,
|
||||
changed_lines: dict[str, set[int]],
|
||||
minimum: float,
|
||||
) -> CoverageTotals:
|
||||
"""Require added executable glue lines to be exercised by the focused unit suite."""
|
||||
covered = 0
|
||||
total = 0
|
||||
for path, lines in sorted(changed_lines.items()):
|
||||
if not lines:
|
||||
continue
|
||||
file_coverage = report["files"].get(path)
|
||||
if file_coverage is None:
|
||||
raise CoverageGateError(f"coverage report is missing changed glue file: {path}")
|
||||
executed_lines = set(file_coverage["executed_lines"])
|
||||
executable_lines = executed_lines | set(file_coverage["missing_lines"])
|
||||
changed_executable_lines = lines & executable_lines
|
||||
covered += len(changed_executable_lines & executed_lines)
|
||||
total += len(changed_executable_lines)
|
||||
|
||||
totals = CoverageTotals(covered=covered, total=total)
|
||||
_require_minimum(totals, minimum=minimum, label="Dify KnowledgeFS changed-glue line coverage")
|
||||
return totals
|
||||
|
||||
|
||||
def _require_minimum(totals: CoverageTotals, *, minimum: float, label: str) -> None:
|
||||
if totals.percent + 1e-12 < minimum:
|
||||
raise CoverageGateError(f"{label} is {totals.percent:.2f}%; minimum {minimum:.2f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Validate Dify Console KnowledgeFS declarations against a pinned OpenAPI document.
|
||||
"""Verify Dify against checked-in contracts from an exact standalone KnowledgeFS commit.
|
||||
|
||||
The OpenAPI document is exported only during explicit development validation. Runtime declarations live with Dify
|
||||
product policy; this module validates their transport metadata without generating a complete operation catalog.
|
||||
CI uses only api/knowledge-fs-contract: no private source checkout, Node runtime, or network
|
||||
is needed for --check. Intentional updates export from a clean --knowledge-fs-root checkout,
|
||||
record its full commit/tree provenance, and pin every artifact and Dify operation manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,29 +13,39 @@ import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
API_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
from dev.knowledge_fs_product_contract import (
|
||||
capability_operation_runtime_contracts,
|
||||
parse_capability_operation_policy,
|
||||
parse_product_operation_gap_manifest,
|
||||
parse_product_operation_manifest,
|
||||
product_operation_runtime_contracts,
|
||||
validate_product_operation_contracts,
|
||||
)
|
||||
|
||||
WORKSPACE_ROOT = API_ROOT.parent
|
||||
LOCK_PATH = API_ROOT / "knowledge-fs-contract.lock.json"
|
||||
DEFAULT_REPOSITORY = WORKSPACE_ROOT.parent / "knowledge-fs"
|
||||
PIN_RELATIVE_PATH = Path("api/knowledge-fs-contract")
|
||||
OPENAPI_FILENAME = "knowledge-fs.openapi.json"
|
||||
CAPABILITY_POLICY_FILENAME = "dify-capability-v2-operations.json"
|
||||
CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH = Path("contracts/dify-capability-v2-auth-profile.json")
|
||||
CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH = Path("contracts/dify-capability-v2-test-vector.json")
|
||||
PROVENANCE_FILENAME = "source-provenance.json"
|
||||
LOCK_RELATIVE_PATH = Path("api/knowledge-fs-contract.lock.json")
|
||||
PRODUCT_OPERATIONS_RELATIVE_PATH = Path("api/knowledge-fs-product-operations.json")
|
||||
PRODUCT_OPERATION_GAPS_RELATIVE_PATH = Path("api/knowledge-fs-product-operation-gaps.json")
|
||||
OPENAPI_METHODS = ("delete", "get", "head", "options", "patch", "post", "put", "trace")
|
||||
PROXY_METHODS = frozenset({"delete", "get", "patch", "post", "put"})
|
||||
CONSOLE_PROXY_ERROR_SCHEMA_NAME = "ConsoleProxyError"
|
||||
CONSOLE_PROXY_ERROR_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"required": ["code", "message", "status"],
|
||||
"properties": {
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"status": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
LOCK_SCHEMA_VERSION = 6
|
||||
|
||||
|
||||
class ContractDeclaration(TypedDict):
|
||||
@@ -49,7 +60,21 @@ class ContractDeclaration(TypedDict):
|
||||
request_headers: tuple[str, ...]
|
||||
response_headers: tuple[str, ...]
|
||||
response_media_types: tuple[str, ...]
|
||||
error_status_map: tuple[tuple[int, int], ...]
|
||||
|
||||
|
||||
class ContractLock(TypedDict):
|
||||
"""Content-addressed contract inputs that must move together."""
|
||||
|
||||
schemaVersion: int
|
||||
sourceCommit: str
|
||||
sourceTree: str
|
||||
sourceProvenanceSha256: str
|
||||
capabilityPolicySha256: str
|
||||
openapiSha256: str
|
||||
capabilityV2AuthManifestSha256: str
|
||||
capabilityV2AuthTestVectorSha256: str
|
||||
productOperationManifestSha256: str
|
||||
productOperationGapManifestSha256: str
|
||||
|
||||
|
||||
type DeclarationField = Literal[
|
||||
@@ -76,66 +101,433 @@ DECLARATION_FIELDS: tuple[DeclarationField, ...] = (
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Update or verify the pin and validate Console declarations against its OpenAPI document."""
|
||||
"""Update an exact source pin or verify the self-contained Dify contract boundary."""
|
||||
parser = argparse.ArgumentParser()
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--check", action="store_true")
|
||||
mode.add_argument("--update-lock", action="store_true")
|
||||
parser.add_argument("--repository", type=Path, default=DEFAULT_REPOSITORY)
|
||||
parser.add_argument("--output-openapi", type=Path)
|
||||
parser.add_argument("--workspace-root", type=Path, default=WORKSPACE_ROOT)
|
||||
parser.add_argument("--knowledge-fs-root", type=Path, help="Clean standalone source checkout (updates only)")
|
||||
args = parser.parse_args()
|
||||
if args.check and args.knowledge_fs_root is not None:
|
||||
parser.error("--check reads pinned artifacts only; --knowledge-fs-root is for --update-lock")
|
||||
if args.update_lock and args.knowledge_fs_root is None:
|
||||
parser.error("--update-lock requires --knowledge-fs-root pointing to a clean committed checkout")
|
||||
|
||||
repository = args.repository.resolve()
|
||||
lock = json.loads(LOCK_PATH.read_text())
|
||||
tracked_changes = run("git", "status", "--porcelain", "--untracked-files=no", cwd=repository).strip()
|
||||
if tracked_changes:
|
||||
raise RuntimeError("KnowledgeFS checkout must not contain tracked changes during contract export")
|
||||
|
||||
commit = run("git", "rev-parse", "HEAD", cwd=repository).strip()
|
||||
if not args.update_lock and commit != lock["commit"]:
|
||||
raise RuntimeError(
|
||||
f"KnowledgeFS checkout mismatch: expected {lock['commit']}, received {commit}. "
|
||||
"Use the pinned commit or pass --update-lock intentionally."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dify-knowledge-fs-contract-") as directory:
|
||||
openapi_path = Path(directory) / "knowledge-fs.openapi.json"
|
||||
subprocess.run(
|
||||
["pnpm", "openapi:export", "--", "--output", str(openapi_path)],
|
||||
cwd=repository,
|
||||
check=True,
|
||||
)
|
||||
openapi_content = openapi_path.read_bytes()
|
||||
|
||||
openapi_sha256 = sha256(openapi_content)
|
||||
if not args.update_lock and openapi_sha256 != lock["openapiSha256"]:
|
||||
raise RuntimeError(
|
||||
f"KnowledgeFS OpenAPI hash mismatch: expected {lock['openapiSha256']}, received {openapi_sha256}"
|
||||
)
|
||||
|
||||
document: dict[str, Any] = json.loads(openapi_content)
|
||||
declarations = console_contract_declarations()
|
||||
validate_declarations(document, declarations)
|
||||
|
||||
if args.output_openapi:
|
||||
filtered_document = filter_openapi_document(document, declarations)
|
||||
filtered_document["x-dify-source-openapi-sha256"] = openapi_sha256
|
||||
filtered_document["x-dify-console-declarations-sha256"] = contract_declarations_sha256(declarations)
|
||||
args.output_openapi.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_openapi.write_text(json.dumps(filtered_document, indent=2) + "\n")
|
||||
|
||||
workspace_root = args.workspace_root.resolve()
|
||||
pin_root = workspace_root / PIN_RELATIVE_PATH
|
||||
if args.update_lock:
|
||||
LOCK_PATH.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"commit": commit,
|
||||
"openapiSha256": openapi_sha256,
|
||||
"repository": lock["repository"],
|
||||
},
|
||||
indent=2,
|
||||
with tempfile.TemporaryDirectory(prefix="dify-knowledge-fs-contract-") as directory:
|
||||
exported_root = Path(directory)
|
||||
export_contract(args.knowledge_fs_root.resolve(), exported_root)
|
||||
expected_lock = validate_pinned_contract(workspace_root, exported_root)
|
||||
pin_root.mkdir(parents=True, exist_ok=True)
|
||||
for path in exported_root.iterdir():
|
||||
(pin_root / path.name).write_bytes(path.read_bytes())
|
||||
(workspace_root / LOCK_RELATIVE_PATH).write_text(json.dumps(expected_lock, indent=2) + "\n")
|
||||
return
|
||||
|
||||
expected_lock = validate_pinned_contract(workspace_root, pin_root)
|
||||
received_lock = parse_contract_lock(load_json_object(workspace_root / LOCK_RELATIVE_PATH))
|
||||
for field, expected_value in expected_lock.items():
|
||||
received_value = dict(received_lock)[field]
|
||||
if received_value != expected_value:
|
||||
raise RuntimeError(
|
||||
f"KnowledgeFS contract lock field {field} drifted: "
|
||||
f"expected {expected_value!r}, received {received_value!r}. "
|
||||
"Run --update-lock --knowledge-fs-root intentionally after reviewing source and contract changes."
|
||||
)
|
||||
+ "\n"
|
||||
|
||||
|
||||
def export_contract(knowledge_fs_root: Path, output_root: Path) -> None:
|
||||
"""Export only a clean committed source tree so provenance identifies the actual input."""
|
||||
ensure_clean_knowledge_fs_worktree(knowledge_fs_root)
|
||||
provenance = {
|
||||
"schemaVersion": 1,
|
||||
"repository": "https://github.com/langgenius/knowledge-fs",
|
||||
"commit": run("git", "rev-parse", "HEAD", cwd=knowledge_fs_root).strip(),
|
||||
"tree": run("git", "rev-parse", "HEAD^{tree}", cwd=knowledge_fs_root).strip(),
|
||||
}
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
for command, filename in (("openapi:export", OPENAPI_FILENAME), ("capability:export", CAPABILITY_POLICY_FILENAME)):
|
||||
subprocess.run(
|
||||
["pnpm", command, "--", "--output", str(output_root / filename)], cwd=knowledge_fs_root, check=True
|
||||
)
|
||||
for relative_path in (CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH, CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH):
|
||||
(output_root / relative_path.name).write_bytes((knowledge_fs_root / relative_path).read_bytes())
|
||||
# Detect source changes made while the export subprocesses ran.
|
||||
ensure_clean_knowledge_fs_worktree(knowledge_fs_root)
|
||||
if run("git", "rev-parse", "HEAD", cwd=knowledge_fs_root).strip() != provenance["commit"]:
|
||||
raise RuntimeError("KnowledgeFS source commit changed during contract export")
|
||||
(output_root / PROVENANCE_FILENAME).write_text(json.dumps(provenance, indent=2) + "\n")
|
||||
|
||||
|
||||
def ensure_clean_knowledge_fs_worktree(knowledge_fs_root: Path) -> None:
|
||||
"""Require the standalone checkout's working tree and index to match its commit."""
|
||||
if run("git", "status", "--porcelain", "--untracked-files=all", cwd=knowledge_fs_root).strip():
|
||||
raise RuntimeError("KnowledgeFS source contains staged, unstaged or untracked changes; commit before export")
|
||||
|
||||
|
||||
def validate_pinned_contract(workspace_root: Path, pin_root: Path) -> ContractLock:
|
||||
"""Validate pinned bytes against executable Python declarations without invoking source tools."""
|
||||
openapi_path = pin_root / OPENAPI_FILENAME
|
||||
policy_path = pin_root / CAPABILITY_POLICY_FILENAME
|
||||
auth_path = pin_root / CAPABILITY_V2_AUTH_MANIFEST_RELATIVE_PATH.name
|
||||
vector_path = pin_root / CAPABILITY_V2_AUTH_TEST_VECTOR_RELATIVE_PATH.name
|
||||
provenance_path = pin_root / PROVENANCE_FILENAME
|
||||
product_path = workspace_root / PRODUCT_OPERATIONS_RELATIVE_PATH
|
||||
gaps_path = workspace_root / PRODUCT_OPERATION_GAPS_RELATIVE_PATH
|
||||
for path in (openapi_path, policy_path, auth_path, vector_path, provenance_path, product_path, gaps_path):
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"KnowledgeFS pinned contract input is missing: {path}")
|
||||
auth_manifest = load_json_object(auth_path)
|
||||
validate_capability_v2_auth_manifest(auth_manifest)
|
||||
validate_capability_v2_auth_test_vector(load_json_object(vector_path), auth_manifest)
|
||||
provenance = load_json_object(provenance_path)
|
||||
validate_upstream_provenance(provenance)
|
||||
document = load_json_object(openapi_path)
|
||||
validate_product_operation_contracts(
|
||||
capability_operations=capability_operation_runtime_contracts(),
|
||||
capability_policy=parse_capability_operation_policy(load_json_object(policy_path)),
|
||||
document=document,
|
||||
gap_manifest=parse_product_operation_gap_manifest(load_json_object(gaps_path)),
|
||||
manifest=parse_product_operation_manifest(load_json_object(product_path)),
|
||||
product_operations=product_operation_runtime_contracts(),
|
||||
)
|
||||
validate_declarations(document, console_contract_declarations())
|
||||
return {
|
||||
"schemaVersion": LOCK_SCHEMA_VERSION,
|
||||
"sourceCommit": provenance["commit"],
|
||||
"sourceTree": provenance["tree"],
|
||||
"sourceProvenanceSha256": sha256(provenance_path.read_bytes()),
|
||||
"openapiSha256": sha256(openapi_path.read_bytes()),
|
||||
"capabilityPolicySha256": sha256(policy_path.read_bytes()),
|
||||
"capabilityV2AuthManifestSha256": sha256(auth_path.read_bytes()),
|
||||
"capabilityV2AuthTestVectorSha256": sha256(vector_path.read_bytes()),
|
||||
"productOperationManifestSha256": sha256(product_path.read_bytes()),
|
||||
"productOperationGapManifestSha256": sha256(gaps_path.read_bytes()),
|
||||
}
|
||||
|
||||
|
||||
def load_json_object(path: Path) -> dict[str, Any]:
|
||||
"""Load a JSON object and reject arrays/scalars at contract boundaries."""
|
||||
value = json.loads(path.read_text())
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"KnowledgeFS contract file must contain a JSON object: {path}")
|
||||
return cast(dict[str, Any], value)
|
||||
|
||||
|
||||
def parse_contract_lock(value: dict[str, Any]) -> ContractLock:
|
||||
"""Validate the compact, non-self-referential contract lock schema."""
|
||||
expected_fields = {
|
||||
"capabilityV2AuthManifestSha256",
|
||||
"capabilityV2AuthTestVectorSha256",
|
||||
"openapiSha256",
|
||||
"productOperationGapManifestSha256",
|
||||
"productOperationManifestSha256",
|
||||
"schemaVersion",
|
||||
"sourceCommit",
|
||||
"sourceTree",
|
||||
"sourceProvenanceSha256",
|
||||
"capabilityPolicySha256",
|
||||
}
|
||||
if set(value) != expected_fields:
|
||||
raise ValueError(f"KnowledgeFS contract lock fields must be exactly {sorted(expected_fields)}")
|
||||
if value.get("schemaVersion") != LOCK_SCHEMA_VERSION:
|
||||
raise ValueError(f"KnowledgeFS contract lock schemaVersion must be {LOCK_SCHEMA_VERSION}")
|
||||
for field in (
|
||||
"capabilityV2AuthManifestSha256",
|
||||
"capabilityV2AuthTestVectorSha256",
|
||||
"openapiSha256",
|
||||
"productOperationGapManifestSha256",
|
||||
"productOperationManifestSha256",
|
||||
"sourceCommit",
|
||||
"sourceTree",
|
||||
"sourceProvenanceSha256",
|
||||
"capabilityPolicySha256",
|
||||
):
|
||||
field_value = value.get(field)
|
||||
expected_length = 40 if field in {"sourceCommit", "sourceTree"} else 64
|
||||
if (
|
||||
not isinstance(field_value, str)
|
||||
or len(field_value) != expected_length
|
||||
or any(character not in "0123456789abcdef" for character in field_value)
|
||||
):
|
||||
raise ValueError(f"KnowledgeFS contract lock field {field} has an invalid digest")
|
||||
return cast(ContractLock, value)
|
||||
|
||||
|
||||
def validate_required_product_operations(document: dict[str, Any], required_operation_ids: list[str]) -> None:
|
||||
"""Fail when the pinned KFS OpenAPI omits an operation required by the Dify product."""
|
||||
available_operation_ids = [
|
||||
operation_id
|
||||
for path_item in document.get("paths", {}).values()
|
||||
for method in OPENAPI_METHODS
|
||||
if isinstance(path_item, dict)
|
||||
for operation in (path_item.get(method),)
|
||||
if isinstance(operation, dict)
|
||||
for operation_id in (operation.get("operationId"),)
|
||||
if isinstance(operation_id, str) and operation_id
|
||||
]
|
||||
for operation_id in required_operation_ids:
|
||||
count = available_operation_ids.count(operation_id)
|
||||
if count != 1:
|
||||
raise ValueError(
|
||||
f"KnowledgeFS OpenAPI required product operation {operation_id} must occur exactly once; found {count}"
|
||||
)
|
||||
|
||||
|
||||
def validate_capability_v2_auth_manifest(value: dict[str, Any]) -> None:
|
||||
"""Validate the active production RS256 profile consumed by both Dify and KnowledgeFS."""
|
||||
expected_fields = {
|
||||
"active",
|
||||
"audience",
|
||||
"callerProfiles",
|
||||
"claimBindings",
|
||||
"issuer",
|
||||
"lifecycle",
|
||||
"maxTtlSeconds",
|
||||
"productionReady",
|
||||
"profileId",
|
||||
"protectedHeader",
|
||||
"requiredClaims",
|
||||
"resourceContract",
|
||||
"runtimeAssembly",
|
||||
"schemaVersion",
|
||||
"signatureAlgorithms",
|
||||
"tokenKind",
|
||||
}
|
||||
if set(value) != expected_fields:
|
||||
raise ValueError(f"KnowledgeFS Capability v2 auth manifest fields must be exactly {sorted(expected_fields)}")
|
||||
fixed_values = {
|
||||
"active": True,
|
||||
"audience": "knowledge-fs",
|
||||
"issuer": "dify-control-plane",
|
||||
"lifecycle": "active",
|
||||
"maxTtlSeconds": 60,
|
||||
"productionReady": True,
|
||||
"profileId": "dify-capability-v2",
|
||||
"schemaVersion": 3,
|
||||
"signatureAlgorithms": ["RS256"],
|
||||
"tokenKind": "jwt",
|
||||
}
|
||||
for field, expected in fixed_values.items():
|
||||
if value.get(field) != expected:
|
||||
raise ValueError(f"KnowledgeFS Capability v2 auth manifest field {field} must be {expected!r}")
|
||||
if value.get("protectedHeader") != {
|
||||
"algorithm": "RS256",
|
||||
"keyIdClaim": "kid",
|
||||
"keyIdRequired": True,
|
||||
"type": "JWT",
|
||||
}:
|
||||
raise ValueError("KnowledgeFS Capability v2 protected header contract is invalid")
|
||||
required_claims = [
|
||||
"action",
|
||||
"actor",
|
||||
"aud",
|
||||
"authz_revision",
|
||||
"azp",
|
||||
"caller_kind",
|
||||
"cap_ver",
|
||||
"content_policy_revision",
|
||||
"content_scope_ids",
|
||||
"control_space_id",
|
||||
"exp",
|
||||
"grant_id",
|
||||
"iat",
|
||||
"iss",
|
||||
"jti",
|
||||
"namespace_id",
|
||||
"nbf",
|
||||
"resource",
|
||||
"sub",
|
||||
"trace_id",
|
||||
]
|
||||
if value.get("requiredClaims") != required_claims:
|
||||
raise ValueError("KnowledgeFS Capability v2 required claims are invalid")
|
||||
if value.get("claimBindings") != {
|
||||
"action": "action",
|
||||
"callerKind": "caller_kind",
|
||||
"controlSpace": "control_space_id",
|
||||
"namespace": "namespace_id",
|
||||
"resource": "resource",
|
||||
"resourceParent": "resource.parent_id",
|
||||
"subject": "sub",
|
||||
}:
|
||||
raise ValueError("KnowledgeFS Capability v2 claim bindings are invalid")
|
||||
if value.get("resourceContract") != {
|
||||
"fields": ["id", "parent_id", "type"],
|
||||
"parentForbiddenFor": ["namespace", "knowledge_space"],
|
||||
"parentRequiredFor": ["document", "job", "query", "research_task", "source", "upload_session"],
|
||||
}:
|
||||
raise ValueError("KnowledgeFS Capability v2 resource contract is invalid")
|
||||
if value.get("callerProfiles") != {
|
||||
"agent": {"authorizedParty": "dify-agent", "subjectPrefix": "dify-app:"},
|
||||
"interactive": {"authorizedParty": "dify-console", "subjectPrefix": "dify-account:"},
|
||||
"internal_worker": {"authorizedParty": "dify-worker", "subjectPrefix": "dify-worker:"},
|
||||
"mcp": {"authorizedParty": "dify-mcp", "subjectPrefix": "dify-mcp-session:"},
|
||||
"service": {"authorizedParty": "dify-service-api", "subjectPrefix": "dify-dataset-api-key:"},
|
||||
"workflow": {"authorizedParty": "dify-workflow", "subjectPrefix": "dify-app:"},
|
||||
}:
|
||||
raise ValueError("KnowledgeFS Capability v2 caller profiles are invalid")
|
||||
if value.get("runtimeAssembly") != {
|
||||
"failClosed": True,
|
||||
"keySelection": "kid",
|
||||
"maximumPublishedKeys": 3,
|
||||
"verificationKeySource": "jwks",
|
||||
}:
|
||||
raise ValueError("KnowledgeFS Capability v2 runtime assembly is invalid")
|
||||
|
||||
|
||||
def validate_capability_v2_auth_test_vector(
|
||||
value: dict[str, Any],
|
||||
manifest: dict[str, Any],
|
||||
) -> None:
|
||||
"""Verify the deterministic public-key vector and every security-sensitive binding."""
|
||||
expected_fields = {
|
||||
"algorithm",
|
||||
"audience",
|
||||
"expectedClaims",
|
||||
"expectedPrincipal",
|
||||
"issuer",
|
||||
"operation",
|
||||
"profileId",
|
||||
"protectedHeader",
|
||||
"publicJwk",
|
||||
"schemaVersion",
|
||||
"testOnly",
|
||||
"token",
|
||||
"ttlSeconds",
|
||||
}
|
||||
if set(value) != expected_fields:
|
||||
raise ValueError(f"KnowledgeFS Capability v2 test vector fields must be exactly {sorted(expected_fields)}")
|
||||
if (
|
||||
value.get("schemaVersion") != 2
|
||||
or value.get("profileId") != manifest.get("profileId")
|
||||
or value.get("testOnly") is not True
|
||||
or value.get("algorithm") != "RS256"
|
||||
or value.get("issuer") != manifest.get("issuer")
|
||||
or value.get("audience") != manifest.get("audience")
|
||||
or value.get("ttlSeconds") != manifest.get("maxTtlSeconds")
|
||||
):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector does not match the active profile")
|
||||
protected_header = value.get("protectedHeader")
|
||||
if not isinstance(protected_header, dict) or set(protected_header) != {"alg", "kid", "typ"}:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector protected header is invalid")
|
||||
kid = protected_header.get("kid")
|
||||
if protected_header.get("alg") != "RS256" or protected_header.get("typ") != "JWT" or not _is_non_blank(kid):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector protected header is invalid")
|
||||
public_jwk = value.get("publicJwk")
|
||||
if not isinstance(public_jwk, dict) or set(public_jwk) != {"alg", "e", "kid", "kty", "n", "use"}:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector public JWK is invalid")
|
||||
if (
|
||||
public_jwk.get("alg") != "RS256"
|
||||
or public_jwk.get("kid") != kid
|
||||
or public_jwk.get("kty") != "RSA"
|
||||
or public_jwk.get("use") != "sig"
|
||||
or not _is_non_blank(public_jwk.get("e"))
|
||||
or not _is_non_blank(public_jwk.get("n"))
|
||||
):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector public JWK is invalid")
|
||||
|
||||
claims = value.get("expectedClaims")
|
||||
required_claims = manifest.get("requiredClaims")
|
||||
if not isinstance(claims, dict) or not isinstance(required_claims, list) or set(claims) != set(required_claims):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector claims do not match the active profile")
|
||||
operation = value.get("operation")
|
||||
if not isinstance(operation, dict) or set(operation) != {"action", "method", "operationId", "requestPath"}:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector operation is invalid")
|
||||
resource = claims.get("resource")
|
||||
if not isinstance(resource, dict) or set(resource) != {"id", "parent_id", "type"}:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector resource is invalid")
|
||||
expected_operation = {
|
||||
"action": "documents.read",
|
||||
"method": "GET",
|
||||
"operationId": "getDocument",
|
||||
"requestPath": "/knowledge-spaces/space-contract-vector/documents/document-contract-vector",
|
||||
}
|
||||
if operation != expected_operation:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector operation binding is invalid")
|
||||
exact_claims = {
|
||||
"action": operation["action"],
|
||||
"aud": value["audience"],
|
||||
"caller_kind": "interactive",
|
||||
"cap_ver": 2,
|
||||
"control_space_id": "control-space-contract-vector",
|
||||
"iss": value["issuer"],
|
||||
"namespace_id": "workspace-contract-vector",
|
||||
"resource": {
|
||||
"id": "document-contract-vector",
|
||||
"parent_id": "space-contract-vector",
|
||||
"type": "document",
|
||||
},
|
||||
"sub": "dify-account:account-contract-vector",
|
||||
}
|
||||
for field, expected in exact_claims.items():
|
||||
if claims.get(field) != expected:
|
||||
raise ValueError(f"KnowledgeFS Capability v2 test vector claim {field} is invalid")
|
||||
if claims.get("actor") != claims["sub"] or claims.get("azp") != "dify-console":
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector caller binding is invalid")
|
||||
issued_at = claims.get("iat")
|
||||
not_before = claims.get("nbf")
|
||||
expires_at = claims.get("exp")
|
||||
if (
|
||||
not isinstance(issued_at, int)
|
||||
or isinstance(issued_at, bool)
|
||||
or not_before != issued_at
|
||||
or not isinstance(expires_at, int)
|
||||
or isinstance(expires_at, bool)
|
||||
or expires_at - issued_at != value["ttlSeconds"]
|
||||
):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector TTL is invalid")
|
||||
expected_principal = {
|
||||
"callerKind": claims["caller_kind"],
|
||||
"subject": {
|
||||
"scopes": ["knowledge-spaces:read"],
|
||||
"subjectId": claims["sub"],
|
||||
"tenantId": claims["namespace_id"],
|
||||
},
|
||||
}
|
||||
if value.get("expectedPrincipal") != expected_principal:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector principal is invalid")
|
||||
|
||||
token = value.get("token")
|
||||
if not isinstance(token, str) or not _is_non_blank(token):
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector token is invalid")
|
||||
try:
|
||||
verification_key = RSAAlgorithm.from_jwk(public_jwk)
|
||||
if not isinstance(verification_key, RSAPublicKey):
|
||||
raise ValueError("Capability vector verification key is not RSA public material")
|
||||
header = jwt.get_unverified_header(token)
|
||||
decoded_claims = jwt.decode(
|
||||
token,
|
||||
verification_key,
|
||||
algorithms=["RS256"],
|
||||
audience=cast(str, value["audience"]),
|
||||
issuer=cast(str, value["issuer"]),
|
||||
options={"verify_exp": False, "verify_iat": False, "verify_nbf": False},
|
||||
)
|
||||
except (jwt.PyJWTError, TypeError, ValueError) as exc:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector signature is invalid") from exc
|
||||
if header != protected_header or decoded_claims != claims:
|
||||
raise ValueError("KnowledgeFS Capability v2 test vector token content drifted")
|
||||
|
||||
|
||||
def _is_non_blank(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip()) and value == value.strip()
|
||||
|
||||
|
||||
def validate_upstream_provenance(value: dict[str, Any]) -> None:
|
||||
"""Validate exact standalone source provenance covered by the schema-6 lock."""
|
||||
expected_fields = {"commit", "tree", "repository", "schemaVersion"}
|
||||
if set(value) != expected_fields or value.get("schemaVersion") != 1:
|
||||
raise ValueError("KnowledgeFS source provenance must use schemaVersion 1 with commit, tree and repository")
|
||||
if value.get("repository") != "https://github.com/langgenius/knowledge-fs":
|
||||
raise ValueError("KnowledgeFS source provenance repository does not match the contract owner")
|
||||
for field in ("commit", "tree"):
|
||||
digest = value.get(field)
|
||||
if not isinstance(digest, str) or len(digest) != 40 or any(c not in "0123456789abcdef" for c in digest):
|
||||
raise ValueError(f"KnowledgeFS source provenance {field} must be a lowercase full Git SHA")
|
||||
|
||||
|
||||
def validate_declarations(document: dict[str, Any], declarations: tuple[ContractDeclaration, ...]) -> None:
|
||||
@@ -168,10 +560,11 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract
|
||||
raise ValueError(f"KnowledgeFS OpenAPI path must be absolute: {path}")
|
||||
if method not in PROXY_METHODS:
|
||||
raise ValueError(f"KnowledgeFS proxy does not support {method.upper()} {path}")
|
||||
expected: dict[DeclarationField, object] = {
|
||||
expected: ContractDeclaration = {
|
||||
"operation_id": operation_id,
|
||||
"method": method.upper(),
|
||||
"path": path[1:],
|
||||
"required_scope": required_scope(operation),
|
||||
"required_scope": required_scope(document, operation),
|
||||
"response_kind": response_kind(operation),
|
||||
"max_response_bytes": required_max_response_bytes(operation),
|
||||
"request_headers": request_header_names(path_item, operation),
|
||||
@@ -186,136 +579,12 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract
|
||||
f"KnowledgeFS operation {operation_id} field {field} drifted: "
|
||||
f"expected {expected_value!r}, received {received_value!r}"
|
||||
)
|
||||
validate_error_status_map(operation_id, declaration["error_status_map"])
|
||||
|
||||
|
||||
def filter_openapi_document(
|
||||
document: dict[str, Any],
|
||||
declarations: tuple[ContractDeclaration, ...],
|
||||
) -> dict[str, Any]:
|
||||
"""Return a code-generation document containing only Console-allowlisted operations."""
|
||||
filtered_document: dict[str, Any] = {
|
||||
key: value for key, value in document.items() if key not in {"components", "paths"}
|
||||
}
|
||||
source_paths = document.get("paths", {})
|
||||
filtered_paths: dict[str, Any] = {}
|
||||
|
||||
for declaration in declarations:
|
||||
path = f"/{declaration['path']}"
|
||||
method = declaration["method"].lower()
|
||||
source_path_item = source_paths[path]
|
||||
path_metadata = {key: value for key, value in source_path_item.items() if key not in OPENAPI_METHODS}
|
||||
filtered_path_item = filtered_paths.setdefault(path, path_metadata)
|
||||
filtered_operation = deepcopy(source_path_item[method])
|
||||
_rewrite_proxy_error_responses(filtered_operation, declaration["error_status_map"])
|
||||
filtered_path_item[method] = filtered_operation
|
||||
|
||||
filtered_document["paths"] = filtered_paths
|
||||
|
||||
source_components = document.get("components", {})
|
||||
filtered_components = {key: value for key, value in source_components.items() if key != "schemas"}
|
||||
source_schemas = source_components.get("schemas", {})
|
||||
available_schemas = {**source_schemas, CONSOLE_PROXY_ERROR_SCHEMA_NAME: CONSOLE_PROXY_ERROR_SCHEMA}
|
||||
schema_names = _referenced_schema_names(filtered_paths, available_schemas)
|
||||
filtered_components["schemas"] = {
|
||||
name: schema for name, schema in available_schemas.items() if name in schema_names
|
||||
}
|
||||
filtered_document["components"] = filtered_components
|
||||
return filtered_document
|
||||
|
||||
|
||||
def validate_error_status_map(operation_id: str, error_status_map: tuple[tuple[int, int], ...]) -> None:
|
||||
"""Validate the status normalization advertised by one Console operation."""
|
||||
upstream_statuses: set[int] = set()
|
||||
for upstream_status, console_status in error_status_map:
|
||||
if upstream_status in upstream_statuses:
|
||||
raise ValueError(f"KnowledgeFS operation {operation_id} has duplicate error status: {upstream_status}")
|
||||
if not 400 <= upstream_status <= 599 or not 400 <= console_status <= 599:
|
||||
raise ValueError(f"KnowledgeFS operation {operation_id} has invalid error status mapping")
|
||||
upstream_statuses.add(upstream_status)
|
||||
|
||||
|
||||
def _rewrite_proxy_error_responses(
|
||||
operation: dict[str, Any],
|
||||
error_status_map: tuple[tuple[int, int], ...],
|
||||
) -> None:
|
||||
responses = operation.setdefault("responses", {})
|
||||
proxy_error_response = {
|
||||
"description": "Error normalized by the Dify Console KnowledgeFS proxy.",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"}}
|
||||
},
|
||||
}
|
||||
for upstream_status, console_status in error_status_map:
|
||||
existing_target = responses.get(str(console_status)) if upstream_status != console_status else None
|
||||
responses.pop(str(upstream_status), None)
|
||||
normalized_response: dict[str, Any] = deepcopy(proxy_error_response)
|
||||
existing_schema = (
|
||||
existing_target.get("content", {}).get("application/json", {}).get("schema")
|
||||
if isinstance(existing_target, dict)
|
||||
else None
|
||||
)
|
||||
if existing_schema is not None:
|
||||
normalized_response["content"]["application/json"]["schema"] = {
|
||||
"oneOf": [
|
||||
deepcopy(existing_schema),
|
||||
{"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"},
|
||||
]
|
||||
}
|
||||
responses[str(console_status)] = normalized_response
|
||||
|
||||
|
||||
def _referenced_schema_names(value: Any, schemas: dict[str, Any]) -> set[str]:
|
||||
reference_prefix = "#/components/schemas/"
|
||||
selected: set[str] = set()
|
||||
pending: list[Any] = [value]
|
||||
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if isinstance(current, list):
|
||||
pending.extend(current)
|
||||
continue
|
||||
if not isinstance(current, dict):
|
||||
continue
|
||||
|
||||
reference = current.get("$ref")
|
||||
if isinstance(reference, str) and reference.startswith(reference_prefix):
|
||||
name = reference.removeprefix(reference_prefix)
|
||||
if name not in selected:
|
||||
if name not in schemas:
|
||||
raise ValueError(f"KnowledgeFS OpenAPI references missing schema: {name}")
|
||||
selected.add(name)
|
||||
pending.append(schemas[name])
|
||||
pending.extend(current.values())
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def console_contract_declarations() -> tuple[ContractDeclaration, ...]:
|
||||
"""Return transport declarations from the runtime Console operation registry."""
|
||||
from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS
|
||||
"""The P9 backend exposes only typed product controllers; the raw Console proxy is removed."""
|
||||
|
||||
return tuple(
|
||||
{
|
||||
"operation_id": operation.operation_id,
|
||||
"method": operation.method,
|
||||
"path": operation.path,
|
||||
"required_scope": operation.required_scope,
|
||||
"response_kind": operation.response_kind,
|
||||
"max_response_bytes": operation.max_response_bytes,
|
||||
"request_headers": operation.request_headers,
|
||||
"response_headers": operation.response_headers,
|
||||
"response_media_types": operation.response_media_types,
|
||||
"error_status_map": operation.error_status_map,
|
||||
}
|
||||
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS
|
||||
)
|
||||
|
||||
|
||||
def contract_declarations_sha256(declarations: tuple[ContractDeclaration, ...]) -> str:
|
||||
"""Return a stable digest for the runtime Console operation declarations."""
|
||||
content = json.dumps(declarations, separators=(",", ":"), sort_keys=True).encode()
|
||||
return sha256(content)
|
||||
return ()
|
||||
|
||||
|
||||
def response_kind(operation: dict[str, Any]) -> str:
|
||||
@@ -335,12 +604,17 @@ def response_media_types(operation: dict[str, Any]) -> tuple[str, ...]:
|
||||
return tuple(sorted(media_types))
|
||||
|
||||
|
||||
def required_scope(operation: dict[str, Any]) -> str | None:
|
||||
def required_scope(document: dict[str, Any], operation: dict[str, Any]) -> str | None:
|
||||
scope = operation.get("x-knowledge-fs-required-scope")
|
||||
security = operation["security"] if "security" in operation else document.get("security")
|
||||
if security == []:
|
||||
if scope is not None:
|
||||
raise ValueError(f"KnowledgeFS public operation must not declare a required scope: {scope}")
|
||||
return None
|
||||
if security != [{"bearerAuth": []}]:
|
||||
raise ValueError(f"KnowledgeFS operation effective security must be exactly bearerAuth: {security}")
|
||||
if scope in ("knowledge-spaces:read", "knowledge-spaces:write"):
|
||||
return scope
|
||||
if operation.get("security") == []:
|
||||
return None
|
||||
raise ValueError(f"KnowledgeFS operation has no supported required scope: {scope}")
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user