2024-08-15 09:17:36 +08:00
#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
2025-06-18 16:45:42 +08:00
import hashlib
2024-08-15 09:17:36 +08:00
import inspect
2025-03-24 13:18:47 +08:00
import logging
import operator
2024-08-15 09:17:36 +08:00
import os
import sys
2025-03-25 15:09:56 +08:00
import time
2025-06-18 16:45:42 +08:00
import typing
2025-11-03 19:59:18 +08:00
from datetime import datetime , timezone
2024-09-12 15:12:39 +08:00
from enum import Enum
2024-08-15 09:17:36 +08:00
from functools import wraps
2025-03-24 13:18:47 +08:00
2025-11-18 17:05:16 +08:00
from quart_auth import AuthUser
2025-03-24 13:18:47 +08:00
from itsdangerous . url_safe import URLSafeTimedSerializer as Serializer
2026-02-27 12:55:51 +01:00
from peewee import (
fn ,
InterfaceError ,
OperationalError ,
ProgrammingError ,
BigIntegerField ,
BooleanField ,
CharField ,
CompositeKey ,
DateTimeField ,
Field ,
FloatField ,
IntegerField ,
Metadata ,
Model ,
TextField ,
2026-03-05 17:27:17 +08:00
PrimaryKeyField ,
2026-02-27 12:55:51 +01:00
)
2024-09-12 15:12:39 +08:00
from playhouse . migrate import MySQLMigrator , PostgresqlMigrator , migrate
from playhouse . pool import PooledMySQLDatabase , PooledPostgresqlDatabase
2024-11-19 14:51:33 +08:00
2025-11-06 09:36:38 +08:00
from api import utils
2025-11-05 08:01:39 +08:00
from api . db import SerializedType
2025-10-29 12:19:57 +08:00
from api . utils . json_encode import json_dumps , json_loads
2025-09-25 18:04:49 +08:00
from api . utils . configs import deserialize_b64 , serialize_b64
2024-08-15 09:17:36 +08:00
2025-10-28 19:09:14 +08:00
from common . time_utils import current_timestamp , timestamp_to_date , date_string_to_timestamp
2025-11-02 12:24:08 +08:00
from common . decorator import singleton
Fix: Remove hardcoded page limits causing parsing failures on large PDFs (>300 pages) (#14382)
### What problem does this PR solve?
Fixes #14196
## Problem
When using DeepDOC to parse large PDFs (over 1000 pages), the parser
silently truncated processing at 300 pages due to a hardcoded default
`page_to=299` in `RAGFlowPdfParser.__images__()`. This caused:
- **Errors** on pages beyond the limit
- **Poor image quality** as the parser attempted to compensate with
missing page data
- **Inconsistent chunk splitting** between full PDF imports and partial
imports
Additionally, the codebase scattered magic numbers (`299`, `600`,
`10000`, `100000`, `100000000`, `10000000000`, `10**9`) across 22 files
as sentinel values for "parse all pages", making future maintenance
error-prone.
## Root Cause
```python
# deepdoc/parser/pdf_parser.py (before)
def __images__(self, fnm, zoomin=3, page_from=0, page_to=299, callback=None):
# Only the first 300 pages were rendered; everything beyond was silently dropped
```
While most callers in `rag/app/*.py` correctly passed `to_page=100000`,
the base class `RAGFlowPdfParser.__call__()` and `parse_into_bboxes()`
invoked `__images__` **without** forwarding `page_from`/`page_to`,
falling back to the restrictive default of 299.
## Solution
### 1. Define constants in `common/constants.py`
```python
MAXIMUM_PAGE_NUMBER = 100000 # Used by the parsing layer
MAXIMUM_TASK_PAGE_NUMBER = MAXIMUM_PAGE_NUMBER * 1000 # Used by the task/DB layer
```
### 2. Replace all hardcoded sentinel values
| Layer | Files Changed | Old Values | New Value |
|---|---|---|---|
| **Deepdoc parsers** | `pdf_parser.py`, `mineru_parser.py`,
`docling_parser.py`, `opendataloader_parser.py`, `paddleocr_parser.py`,
`docx_parser.py` | `299`, `600`, `10**9`, `100000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Chunk parsers** | `naive.py`, `book.py`, `qa.py`, `one.py`,
`manual.py`, `paper.py`, `presentation.py`, `laws.py`, `resume.py`,
`email.py`, `table.py` | `100000`, `10000`, `10000000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Task/DB layer** | `db_models.py`, `task_service.py`,
`document_service.py`, `file_service.py` | `100000000` |
`MAXIMUM_TASK_PAGE_NUMBER` |
### 3. Fix `parse_into_bboxes()` missing parameters
Added `from_page`/`to_page` parameters to `parse_into_bboxes()` so that
the `rag/flow/parser/parser.py` DeepDOC path no longer falls back to the
restrictive default.
## Files Changed (22)
- `common/constants.py`
- `deepdoc/parser/pdf_parser.py`
- `deepdoc/parser/mineru_parser.py`
- `deepdoc/parser/docling_parser.py`
- `deepdoc/parser/opendataloader_parser.py`
- `deepdoc/parser/paddleocr_parser.py`
- `deepdoc/parser/docx_parser.py`
- `rag/app/naive.py`
- `rag/app/book.py`
- `rag/app/qa.py`
- `rag/app/one.py`
- `rag/app/manual.py`
- `rag/app/paper.py`
- `rag/app/presentation.py`
- `rag/app/laws.py`
- `rag/app/resume.py`
- `rag/app/email.py`
- `rag/app/table.py`
- `api/db/db_models.py`
- `api/db/services/task_service.py`
- `api/db/services/document_service.py`
- `api/db/services/file_service.py`
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
---------
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-27 06:57:20 +00:00
from common . constants import ParserType , MAXIMUM_TASK_PAGE_NUMBER
2025-11-06 09:36:38 +08:00
from common import settings
2024-08-15 09:17:36 +08:00
CONTINUOUS_FIELD_TYPE = { IntegerField , FloatField , DateTimeField }
2025-03-24 13:18:47 +08:00
AUTO_DATE_TIMESTAMP_FIELD_PREFIX = { " create " , " start " , " end " , " update " , " read_access " , " write_access " }
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
class TextFieldType ( Enum ) :
2025-03-24 13:18:47 +08:00
MYSQL = " LONGTEXT "
2026-01-31 15:45:20 +08:00
OCEANBASE = " LONGTEXT "
POSTGRES = " TEXT "
2024-09-12 15:12:39 +08:00
2024-08-15 09:17:36 +08:00
class LongTextField ( TextField ) :
2024-11-15 17:30:56 +08:00
field_type = TextFieldType [ settings . DATABASE_TYPE . upper ( ) ] . value
2024-08-15 09:17:36 +08:00
class JSONField ( LongTextField ) :
default_value = { }
def __init__ ( self , object_hook = None , object_pairs_hook = None , * * kwargs ) :
self . _object_hook = object_hook
self . _object_pairs_hook = object_pairs_hook
super ( ) . __init__ ( * * kwargs )
def db_value ( self , value ) :
if value is None :
value = self . default_value
2025-09-25 18:04:49 +08:00
return json_dumps ( value )
2024-08-15 09:17:36 +08:00
def python_value ( self , value ) :
if not value :
return self . default_value
2025-09-25 18:04:49 +08:00
return json_loads ( value , object_hook = self . _object_hook , object_pairs_hook = self . _object_pairs_hook )
2024-08-15 09:17:36 +08:00
class ListField ( JSONField ) :
default_value = [ ]
class SerializedField ( LongTextField ) :
2025-03-24 13:18:47 +08:00
def __init__ ( self , serialized_type = SerializedType . PICKLE , object_hook = None , object_pairs_hook = None , * * kwargs ) :
2024-08-15 09:17:36 +08:00
self . _serialized_type = serialized_type
self . _object_hook = object_hook
self . _object_pairs_hook = object_pairs_hook
super ( ) . __init__ ( * * kwargs )
def db_value ( self , value ) :
if self . _serialized_type == SerializedType . PICKLE :
2025-09-25 18:04:49 +08:00
return serialize_b64 ( value , to_str = True )
2024-08-15 09:17:36 +08:00
elif self . _serialized_type == SerializedType . JSON :
if value is None :
return None
2025-09-25 18:04:49 +08:00
return json_dumps ( value , with_type = True )
2024-08-15 09:17:36 +08:00
else :
2025-03-24 13:18:47 +08:00
raise ValueError ( f " the serialized type { self . _serialized_type } is not supported " )
2024-08-15 09:17:36 +08:00
def python_value ( self , value ) :
if self . _serialized_type == SerializedType . PICKLE :
2025-09-25 18:04:49 +08:00
return deserialize_b64 ( value )
2024-08-15 09:17:36 +08:00
elif self . _serialized_type == SerializedType . JSON :
if value is None :
return { }
2025-09-25 18:04:49 +08:00
return json_loads ( value , object_hook = self . _object_hook , object_pairs_hook = self . _object_pairs_hook )
2024-08-15 09:17:36 +08:00
else :
2025-03-24 13:18:47 +08:00
raise ValueError ( f " the serialized type { self . _serialized_type } is not supported " )
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
def is_continuous_field ( cls : typing . Type ) - > bool :
2024-08-15 09:17:36 +08:00
if cls in CONTINUOUS_FIELD_TYPE :
return True
for p in cls . __bases__ :
if p in CONTINUOUS_FIELD_TYPE :
return True
2024-12-08 14:21:12 +08:00
elif p is not Field and p is not object :
2024-08-15 09:17:36 +08:00
if is_continuous_field ( p ) :
return True
else :
return False
def auto_date_timestamp_field ( ) :
return { f " { f } _time " for f in AUTO_DATE_TIMESTAMP_FIELD_PREFIX }
def auto_date_timestamp_db_field ( ) :
return { f " f_ { f } _time " for f in AUTO_DATE_TIMESTAMP_FIELD_PREFIX }
def remove_field_name_prefix ( field_name ) :
2025-03-24 13:18:47 +08:00
return field_name [ 2 : ] if field_name . startswith ( " f_ " ) else field_name
2024-08-15 09:17:36 +08:00
class BaseModel ( Model ) :
create_time = BigIntegerField ( null = True , index = True )
create_date = DateTimeField ( null = True , index = True )
update_time = BigIntegerField ( null = True , index = True )
update_date = DateTimeField ( null = True , index = True )
def to_json ( self ) :
# This function is obsolete
return self . to_dict ( )
def to_dict ( self ) :
2025-03-24 13:18:47 +08:00
return self . __dict__ [ " __data__ " ]
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
def to_human_model_dict ( self , only_primary_with : list = None ) :
2025-03-24 13:18:47 +08:00
model_dict = self . __dict__ [ " __data__ " ]
2024-08-15 09:17:36 +08:00
if not only_primary_with :
2025-03-24 13:18:47 +08:00
return { remove_field_name_prefix ( k ) : v for k , v in model_dict . items ( ) }
2024-08-15 09:17:36 +08:00
human_model_dict = { }
for k in self . _meta . primary_key . field_names :
human_model_dict [ remove_field_name_prefix ( k ) ] = model_dict [ k ]
for k in only_primary_with :
2025-03-24 13:18:47 +08:00
human_model_dict [ k ] = model_dict [ f " f_ { k } " ]
2024-08-15 09:17:36 +08:00
return human_model_dict
@property
def meta ( self ) - > Metadata :
return self . _meta
@classmethod
def get_primary_keys_name ( cls ) :
2025-03-24 13:18:47 +08:00
return cls . _meta . primary_key . field_names if isinstance ( cls . _meta . primary_key , CompositeKey ) else [ cls . _meta . primary_key . name ]
2024-08-15 09:17:36 +08:00
@classmethod
def getter_by ( cls , attr ) :
return operator . attrgetter ( attr ) ( cls )
@classmethod
def query ( cls , reverse = None , order_by = None , * * kwargs ) :
filters = [ ]
for f_n , f_v in kwargs . items ( ) :
2025-03-24 13:18:47 +08:00
attr_name = " %s " % f_n
2024-08-15 09:17:36 +08:00
if not hasattr ( cls , attr_name ) or f_v is None :
continue
if type ( f_v ) in { list , set } :
f_v = list ( f_v )
if is_continuous_field ( type ( getattr ( cls , attr_name ) ) ) :
if len ( f_v ) == 2 :
for i , v in enumerate ( f_v ) :
2025-03-24 13:18:47 +08:00
if isinstance ( v , str ) and f_n in auto_date_timestamp_field ( ) :
2024-08-15 09:17:36 +08:00
# time type: %Y-%m-%d %H:%M:%S
2025-10-28 19:09:14 +08:00
f_v [ i ] = date_string_to_timestamp ( v )
2024-08-15 09:17:36 +08:00
lt_value = f_v [ 0 ]
gt_value = f_v [ 1 ]
if lt_value is not None and gt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( cls . getter_by ( attr_name ) . between ( lt_value , gt_value ) )
2024-08-15 09:17:36 +08:00
elif lt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( operator . attrgetter ( attr_name ) ( cls ) > = lt_value )
2024-08-15 09:17:36 +08:00
elif gt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( operator . attrgetter ( attr_name ) ( cls ) < = gt_value )
2024-08-15 09:17:36 +08:00
else :
filters . append ( operator . attrgetter ( attr_name ) ( cls ) << f_v )
else :
filters . append ( operator . attrgetter ( attr_name ) ( cls ) == f_v )
if filters :
query_records = cls . select ( ) . where ( * filters )
if reverse is not None :
if not order_by or not hasattr ( cls , f " { order_by } " ) :
order_by = " create_time "
if reverse is True :
2025-03-24 13:18:47 +08:00
query_records = query_records . order_by ( cls . getter_by ( f " { order_by } " ) . desc ( ) )
2024-08-15 09:17:36 +08:00
elif reverse is False :
2025-03-24 13:18:47 +08:00
query_records = query_records . order_by ( cls . getter_by ( f " { order_by } " ) . asc ( ) )
2024-08-15 09:17:36 +08:00
return [ query_record for query_record in query_records ]
else :
return [ ]
@classmethod
def insert ( cls , __data = None , * * insert ) :
if isinstance ( __data , dict ) and __data :
2025-10-28 19:09:14 +08:00
__data [ cls . _meta . combined [ " create_time " ] ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
if insert :
2025-10-28 19:09:14 +08:00
insert [ " create_time " ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
return super ( ) . insert ( __data , * * insert )
# update and insert will call this method
@classmethod
def _normalize_data ( cls , data , kwargs ) :
normalized = super ( ) . _normalize_data ( data , kwargs )
if not normalized :
return { }
2025-10-28 19:09:14 +08:00
normalized [ cls . _meta . combined [ " update_time " ] ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
for f_n in AUTO_DATE_TIMESTAMP_FIELD_PREFIX :
2025-03-24 13:18:47 +08:00
if { f " { f_n } _time " , f " { f_n } _date " } . issubset ( cls . _meta . combined . keys ( ) ) and cls . _meta . combined [ f " { f_n } _time " ] in normalized and normalized [ cls . _meta . combined [ f " { f_n } _time " ] ] is not None :
2025-10-28 19:09:14 +08:00
normalized [ cls . _meta . combined [ f " { f_n } _date " ] ] = timestamp_to_date ( normalized [ cls . _meta . combined [ f " { f_n } _time " ] ] )
2024-08-15 09:17:36 +08:00
return normalized
class JsonSerializedField ( SerializedField ) :
2025-03-24 13:18:47 +08:00
def __init__ ( self , object_hook = utils . from_dict_hook , object_pairs_hook = None , * * kwargs ) :
super ( JsonSerializedField , self ) . __init__ ( serialized_type = SerializedType . JSON , object_hook = object_hook , object_pairs_hook = object_pairs_hook , * * kwargs )
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
2025-07-15 19:05:48 +08:00
class RetryingPooledMySQLDatabase ( PooledMySQLDatabase ) :
def __init__ ( self , * args , * * kwargs ) :
2025-09-03 14:55:24 +08:00
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
2025-07-15 19:05:48 +08:00
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
2025-09-25 17:03:43 +08:00
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
error_messages = [ ' ' , ' Lost connection ' ]
should_retry = (
( hasattr ( e , ' args ' ) and e . args and e . args [ 0 ] in error_codes ) or
( str ( e ) in error_messages ) or
( hasattr ( e , ' __class__ ' ) and e . __class__ . __name__ == ' InterfaceError ' )
)
if should_retry and attempt < self . max_retries :
logging . warning (
f " Database connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } "
)
2025-07-15 19:05:48 +08:00
self . _handle_connection_loss ( )
2025-09-25 17:03:43 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-07-15 19:05:48 +08:00
else :
logging . error ( f " DB execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
2025-09-25 17:03:43 +08:00
# self.close_all()
# self.connect()
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect: { e } " )
time . sleep ( 0.1 )
2026-01-18 17:48:10 -08:00
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect on second attempt: { e2 } " )
raise
2025-07-15 19:05:48 +08:00
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
2025-09-25 17:03:43 +08:00
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
error_messages = [ ' ' , ' Lost connection ' ]
should_retry = (
( hasattr ( e , ' args ' ) and e . args and e . args [ 0 ] in error_codes ) or
( str ( e ) in error_messages ) or
( hasattr ( e , ' __class__ ' ) and e . __class__ . __name__ == ' InterfaceError ' )
)
if should_retry and attempt < self . max_retries :
logging . warning (
f " Lost connection during transaction (attempt { attempt + 1 } / { self . max_retries } ) "
)
2025-07-15 19:05:48 +08:00
self . _handle_connection_loss ( )
2025-09-25 17:03:43 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-07-15 19:05:48 +08:00
else :
raise
2025-11-16 19:29:20 +08:00
return None
2025-07-15 19:05:48 +08:00
2025-10-16 09:08:41 +02:00
class RetryingPooledPostgresqlDatabase ( PooledPostgresqlDatabase ) :
def __init__ ( self , * args , * * kwargs ) :
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
except ( OperationalError , InterfaceError ) as e :
# PostgreSQL specific error codes
# 57P01: admin_shutdown
# 57P02: crash_shutdown
# 57P03: cannot_connect_now
# 08006: connection_failure
# 08003: connection_does_not_exist
# 08000: connection_exception
2025-10-28 19:09:14 +08:00
error_messages = [ ' connection ' , ' server closed ' , ' connection refused ' ,
2025-10-16 09:08:41 +02:00
' no connection to the server ' , ' terminating connection ' ]
2025-10-28 19:09:14 +08:00
2025-10-16 09:08:41 +02:00
should_retry = any ( msg in str ( e ) . lower ( ) for msg in error_messages )
if should_retry and attempt < self . max_retries :
logging . warning (
f " PostgreSQL connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } "
)
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
logging . error ( f " PostgreSQL execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect to PostgreSQL: { e } " )
time . sleep ( 0.1 )
2026-01-18 17:48:10 -08:00
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect to PostgreSQL on second attempt: { e2 } " )
raise
2025-10-16 09:08:41 +02:00
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
except ( OperationalError , InterfaceError ) as e :
error_messages = [ ' connection ' , ' server closed ' , ' connection refused ' ,
' no connection to the server ' , ' terminating connection ' ]
2025-10-28 19:09:14 +08:00
2025-10-16 09:08:41 +02:00
should_retry = any ( msg in str ( e ) . lower ( ) for msg in error_messages )
if should_retry and attempt < self . max_retries :
logging . warning (
f " PostgreSQL connection lost during transaction (attempt { attempt + 1 } / { self . max_retries } ) "
)
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
raise
2025-11-04 14:15:31 +08:00
return None
2025-10-16 09:08:41 +02:00
2026-01-31 15:45:20 +08:00
class RetryingPooledOceanBaseDatabase ( PooledMySQLDatabase ) :
""" Pooled OceanBase database with retry mechanism.
OceanBase is compatible with MySQL protocol , so we inherit from PooledMySQLDatabase .
This class provides connection pooling and automatic retry for connection issues .
"""
def __init__ ( self , * args , * * kwargs ) :
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
except ( OperationalError , InterfaceError ) as e :
# OceanBase/MySQL specific error codes
# 2013: Lost connection to MySQL server during query
# 2006: MySQL server has gone away
error_codes = [ 2013 , 2006 ]
error_messages = [ ' ' , ' Lost connection ' , ' gone away ' ]
should_retry = (
( hasattr ( e , ' args ' ) and e . args and e . args [ 0 ] in error_codes ) or
any ( msg in str ( e ) . lower ( ) for msg in error_messages ) or
( hasattr ( e , ' __class__ ' ) and e . __class__ . __name__ == ' InterfaceError ' )
)
if should_retry and attempt < self . max_retries :
logging . warning (
f " OceanBase connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } "
)
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
logging . error ( f " OceanBase execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect to OceanBase: { e } " )
time . sleep ( 0.1 )
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect to OceanBase on second attempt: { e2 } " )
raise
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
error_messages = [ ' ' , ' Lost connection ' ]
should_retry = (
( hasattr ( e , ' args ' ) and e . args and e . args [ 0 ] in error_codes ) or
( str ( e ) in error_messages ) or
( hasattr ( e , ' __class__ ' ) and e . __class__ . __name__ == ' InterfaceError ' )
)
if should_retry and attempt < self . max_retries :
logging . warning (
f " Lost connection during transaction (attempt { attempt + 1 } / { self . max_retries } ) "
)
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
raise
return None
2024-09-12 15:12:39 +08:00
class PooledDatabase ( Enum ) :
2025-07-15 19:05:48 +08:00
MYSQL = RetryingPooledMySQLDatabase
2026-01-31 15:45:20 +08:00
OCEANBASE = RetryingPooledOceanBaseDatabase
2025-10-16 09:08:41 +02:00
POSTGRES = RetryingPooledPostgresqlDatabase
2024-09-12 15:12:39 +08:00
class DatabaseMigrator ( Enum ) :
MYSQL = MySQLMigrator
2026-01-31 15:45:20 +08:00
OCEANBASE = MySQLMigrator
2024-09-12 15:12:39 +08:00
POSTGRES = PostgresqlMigrator
2024-08-15 09:17:36 +08:00
@singleton
class BaseDataBase :
def __init__ ( self ) :
2024-11-15 17:30:56 +08:00
database_config = settings . DATABASE . copy ( )
2024-08-15 09:17:36 +08:00
db_name = database_config . pop ( " name " )
2025-10-28 19:09:14 +08:00
2025-09-25 17:03:43 +08:00
pool_config = {
' max_retries ' : 5 ,
' retry_delay ' : 1 ,
}
database_config . update ( pool_config )
self . database_connection = PooledDatabase [ settings . DATABASE_TYPE . upper ( ) ] . value (
db_name , * * database_config
)
# self.database_connection = PooledDatabase[settings.DATABASE_TYPE.upper()].value(db_name, **database_config)
2025-03-24 13:18:47 +08:00
logging . info ( " init database on cluster mode successfully " )
2024-09-12 15:12:39 +08:00
2024-11-19 14:51:33 +08:00
2025-03-25 15:09:56 +08:00
def with_retry ( max_retries = 3 , retry_delay = 1.0 ) :
""" Decorator: Add retry mechanism to database operations
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
Args :
max_retries ( int ) : maximum number of retries
retry_delay ( float ) : initial retry delay ( seconds ) , will increase exponentially
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
Returns :
decorated function
"""
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
def decorator ( func ) :
@wraps ( func )
def wrapper ( * args , * * kwargs ) :
last_exception = None
for retry in range ( max_retries ) :
try :
return func ( * args , * * kwargs )
except Exception as e :
last_exception = e
# get self and method name for logging
self_obj = args [ 0 ] if args else None
func_name = func . __name__
2025-06-18 16:45:42 +08:00
lock_name = getattr ( self_obj , " lock_name " , " unknown " ) if self_obj else " unknown "
2025-03-25 15:09:56 +08:00
if retry < max_retries - 1 :
2025-06-18 16:45:42 +08:00
current_delay = retry_delay * ( 2 * * retry )
logging . warning ( f " { func_name } { lock_name } failed: { str ( e ) } , retrying ( { retry + 1 } / { max_retries } ) " )
2025-03-25 15:09:56 +08:00
time . sleep ( current_delay )
else :
logging . error ( f " { func_name } { lock_name } failed after all attempts: { str ( e ) } " )
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
if last_exception :
raise last_exception
return False
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
return wrapper
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
return decorator
2024-09-12 15:12:39 +08:00
class PostgresDatabaseLock :
def __init__ ( self , lock_name , timeout = 10 , db = None ) :
self . lock_name = lock_name
2025-06-18 16:45:42 +08:00
self . lock_id = int ( hashlib . md5 ( lock_name . encode ( ) ) . hexdigest ( ) , 16 ) % ( 2 * * 31 - 1 )
2024-09-12 15:12:39 +08:00
self . timeout = int ( timeout )
self . db = db if db else DB
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-09-12 15:12:39 +08:00
def lock ( self ) :
2025-03-25 15:09:56 +08:00
cursor = self . db . execute_sql ( " SELECT pg_try_advisory_lock( %s ) " , ( self . lock_id , ) )
2024-09-12 15:12:39 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " acquire postgres lock { self . lock_name } timeout " )
2024-09-12 15:12:39 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " failed to acquire lock { self . lock_name } " )
2024-09-12 15:12:39 +08:00
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-09-12 15:12:39 +08:00
def unlock ( self ) :
2025-03-25 15:09:56 +08:00
cursor = self . db . execute_sql ( " SELECT pg_advisory_unlock( %s ) " , ( self . lock_id , ) )
2024-09-12 15:12:39 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " postgres lock { self . lock_name } was not established by this thread " )
2024-09-12 15:12:39 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " postgres lock { self . lock_name } does not exist " )
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
def __enter__ ( self ) :
2025-03-25 15:09:56 +08:00
if isinstance ( self . db , PooledPostgresqlDatabase ) :
2024-09-12 15:12:39 +08:00
self . lock ( )
return self
def __exit__ ( self , exc_type , exc_val , exc_tb ) :
2025-03-25 15:09:56 +08:00
if isinstance ( self . db , PooledPostgresqlDatabase ) :
2024-09-12 15:12:39 +08:00
self . unlock ( )
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
def __call__ ( self , func ) :
@wraps ( func )
def magic ( * args , * * kwargs ) :
with self :
return func ( * args , * * kwargs )
return magic
2024-11-19 14:51:33 +08:00
2024-09-12 15:12:39 +08:00
class MysqlDatabaseLock :
2024-08-15 09:17:36 +08:00
def __init__ ( self , lock_name , timeout = 10 , db = None ) :
self . lock_name = lock_name
self . timeout = int ( timeout )
self . db = db if db else DB
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-08-15 09:17:36 +08:00
def lock ( self ) :
# SQL parameters only support %s format placeholders
2025-03-24 13:18:47 +08:00
cursor = self . db . execute_sql ( " SELECT GET_LOCK( %s , %s ) " , ( self . lock_name , self . timeout ) )
2024-08-15 09:17:36 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " acquire mysql lock { self . lock_name } timeout " )
2024-08-15 09:17:36 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " failed to acquire lock { self . lock_name } " )
2024-08-15 09:17:36 +08:00
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-08-15 09:17:36 +08:00
def unlock ( self ) :
2025-03-24 13:18:47 +08:00
cursor = self . db . execute_sql ( " SELECT RELEASE_LOCK( %s ) " , ( self . lock_name , ) )
2024-08-15 09:17:36 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " mysql lock { self . lock_name } was not established by this thread " )
2024-08-15 09:17:36 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " mysql lock { self . lock_name } does not exist " )
2024-08-15 09:17:36 +08:00
def __enter__ ( self ) :
if isinstance ( self . db , PooledMySQLDatabase ) :
self . lock ( )
return self
def __exit__ ( self , exc_type , exc_val , exc_tb ) :
if isinstance ( self . db , PooledMySQLDatabase ) :
self . unlock ( )
def __call__ ( self , func ) :
@wraps ( func )
def magic ( * args , * * kwargs ) :
with self :
return func ( * args , * * kwargs )
return magic
2024-09-12 15:12:39 +08:00
class DatabaseLock ( Enum ) :
MYSQL = MysqlDatabaseLock
2026-01-31 15:45:20 +08:00
OCEANBASE = MysqlDatabaseLock
2024-09-12 15:12:39 +08:00
POSTGRES = PostgresDatabaseLock
2024-08-15 09:17:36 +08:00
DB = BaseDataBase ( ) . database_connection
2024-11-15 17:30:56 +08:00
DB . lock = DatabaseLock [ settings . DATABASE_TYPE . upper ( ) ] . value
2024-08-15 09:17:36 +08:00
def close_connection ( ) :
try :
if DB :
DB . close_stale ( age = 30 )
except Exception as e :
2024-11-14 17:13:48 +08:00
logging . exception ( e )
2024-08-15 09:17:36 +08:00
class DataBaseModel ( BaseModel ) :
class Meta :
database = DB
@DB.connection_context ( )
2025-07-30 19:41:09 +08:00
@DB.lock ( " init_database_tables " , 60 )
2024-08-15 09:17:36 +08:00
def init_database_tables ( alter_fields = [ ] ) :
members = inspect . getmembers ( sys . modules [ __name__ ] , inspect . isclass )
table_objs = [ ]
create_failed_list = [ ]
for name , obj in members :
if obj != DataBaseModel and issubclass ( obj , DataBaseModel ) :
table_objs . append ( obj )
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
if not obj . table_exists ( ) :
logging . debug ( f " start create table { obj . __name__ } " )
try :
2025-07-30 19:41:09 +08:00
obj . create_table ( safe = True )
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
logging . debug ( f " create table success: { obj . __name__ } " )
except Exception as e :
logging . exception ( e )
create_failed_list . append ( obj . __name__ )
else :
logging . debug ( f " table { obj . __name__ } already exists, skip creation. " )
2024-08-15 09:17:36 +08:00
if create_failed_list :
2024-11-14 17:13:48 +08:00
logging . error ( f " create tables failed: { create_failed_list } " )
2024-08-15 09:17:36 +08:00
raise Exception ( f " create tables failed: { create_failed_list } " )
migrate_db ( )
def fill_db_model_object ( model_object , human_model_dict ) :
for k , v in human_model_dict . items ( ) :
2025-03-24 13:18:47 +08:00
attr_name = " %s " % k
2024-08-15 09:17:36 +08:00
if hasattr ( model_object . __class__ , attr_name ) :
setattr ( model_object , attr_name , v )
return model_object
2025-11-18 17:05:16 +08:00
class User ( DataBaseModel , AuthUser ) :
2026-05-22 00:14:26 -07:00
SENSITIVE_FIELDS = { " password " , " access_token " , " email " }
2024-08-15 09:17:36 +08:00
id = CharField ( max_length = 32 , primary_key = True )
access_token = CharField ( max_length = 255 , null = True , index = True )
nickname = CharField ( max_length = 100 , null = False , help_text = " nicky name " , index = True )
password = CharField ( max_length = 255 , null = True , help_text = " password " , index = True )
2026-02-27 12:55:51 +01:00
email = CharField ( max_length = 255 , null = False , help_text = " email " , unique = True )
2024-08-15 09:17:36 +08:00
avatar = TextField ( null = True , help_text = " avatar base64 string " )
2025-03-24 13:18:47 +08:00
language = CharField ( max_length = 32 , null = True , help_text = " English|Chinese " , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , index = True )
color_schema = CharField ( max_length = 32 , null = True , help_text = " Bright|Dark " , default = " Bright " , index = True )
timezone = CharField ( max_length = 64 , null = True , help_text = " Timezone " , default = " UTC+8 \t Asia/Shanghai " , index = True )
2024-08-15 09:17:36 +08:00
last_login_time = DateTimeField ( null = True , index = True )
is_authenticated = CharField ( max_length = 1 , null = False , default = " 1 " , index = True )
is_active = CharField ( max_length = 1 , null = False , default = " 1 " , index = True )
is_anonymous = CharField ( max_length = 1 , null = False , default = " 0 " , index = True )
login_channel = CharField ( null = True , help_text = " from which user login " , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
is_superuser = BooleanField ( null = True , help_text = " is root " , default = False , index = True )
def __str__ ( self ) :
return self . email
def get_id ( self ) :
2026-05-07 10:10:02 +08:00
jwt = Serializer ( secret_key = settings . get_secret_key ( ) )
2024-08-15 09:17:36 +08:00
return jwt . dumps ( str ( self . access_token ) )
2026-05-22 00:14:26 -07:00
def to_safe_dict ( self , * , for_self : bool = False ) :
""" Return a dict with sensitive fields stripped for API responses.
Email is treated as sensitive in generic serialization . Pass for_self = True
when returning the authenticated user ' s own record (login, profile, etc.).
"""
result = { k : v for k , v in self . to_dict ( ) . items ( ) if k not in self . SENSITIVE_FIELDS }
if for_self :
result [ " email " ] = self . email
logging . debug ( " User %s serialized safely, filtered fields: %s " , self . id , self . SENSITIVE_FIELDS )
return result
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user "
class Tenant ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 100 , null = True , help_text = " Tenant name " , index = True )
public_key = CharField ( max_length = 255 , null = True , index = True )
llm_id = CharField ( max_length = 128 , null = False , help_text = " default llm ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_llm_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
embd_id = CharField ( max_length = 128 , null = False , help_text = " default embedding model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_embd_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
asr_id = CharField ( max_length = 128 , null = False , help_text = " default ASR model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_asr_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
img2txt_id = CharField ( max_length = 128 , null = False , help_text = " default image to text model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_img2txt_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
rerank_id = CharField ( max_length = 128 , null = False , help_text = " default rerank model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_rerank_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
tts_id = CharField ( max_length = 256 , null = True , help_text = " default tts model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_tts_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2026-05-29 17:39:41 +08:00
ocr_id = CharField ( max_length = 256 , null = True , help_text = " default OCR model ID " , index = True )
2025-03-24 13:18:47 +08:00
parser_ids = CharField ( max_length = 256 , null = False , help_text = " document processors " , index = True )
2024-08-15 09:17:36 +08:00
credit = IntegerField ( default = 512 , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " tenant "
class UserTenant ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
user_id = CharField ( max_length = 32 , null = False , index = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
role = CharField ( max_length = 32 , null = False , help_text = " UserTenantRole " , index = True )
invited_by = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user_tenant "
class InvitationCode ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
code = CharField ( max_length = 32 , null = False , index = True )
visit_time = DateTimeField ( null = True , index = True )
user_id = CharField ( max_length = 32 , null = True , index = True )
tenant_id = CharField ( max_length = 32 , null = True , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " invitation_code "
class LLMFactories ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 128 , null = False , help_text = " LLM factory name " , primary_key = True )
2024-08-15 09:17:36 +08:00
logo = TextField ( null = True , help_text = " llm logo base64 " )
2025-03-24 13:18:47 +08:00
tags = CharField ( max_length = 255 , null = False , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
2025-11-10 13:28:07 +08:00
rank = IntegerField ( default = 0 , index = False )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " llm_factories "
class LLM ( DataBaseModel ) :
# LLMs dictionary
2025-03-24 13:18:47 +08:00
llm_name = CharField ( max_length = 128 , null = False , help_text = " LLM name " , index = True )
model_type = CharField ( max_length = 128 , null = False , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
2024-08-15 09:17:36 +08:00
fid = CharField ( max_length = 128 , null = False , help_text = " LLM factory id " , index = True )
max_tokens = IntegerField ( default = 0 )
2025-03-24 13:18:47 +08:00
tags = CharField ( max_length = 255 , null = False , help_text = " LLM, Text Embedding, Image2Text, Chat, 32k... " , index = True )
2025-06-18 16:45:42 +08:00
is_tools = BooleanField ( null = False , help_text = " support tools " , default = False )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . llm_name
class Meta :
2025-03-24 13:18:47 +08:00
primary_key = CompositeKey ( " fid " , " llm_name " )
2024-08-15 09:17:36 +08:00
db_table = " llm "
class TenantLLM ( DataBaseModel ) :
2026-03-05 17:27:17 +08:00
id = PrimaryKeyField ( )
2024-08-15 09:17:36 +08:00
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
llm_factory = CharField ( max_length = 128 , null = False , help_text = " LLM factory name " , index = True )
model_type = CharField ( max_length = 128 , null = True , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
llm_name = CharField ( max_length = 128 , null = True , help_text = " LLM name " , default = " " , index = True )
2025-10-10 13:18:24 +02:00
api_key = TextField ( null = True , help_text = " API KEY " )
2024-08-15 09:17:36 +08:00
api_base = CharField ( max_length = 255 , null = True , help_text = " API Base " )
2026-03-05 17:27:17 +08:00
max_tokens = IntegerField ( default = 8192 , help_text = " Max context token num " , index = True )
used_tokens = IntegerField ( default = 0 , help_text = " Used token num " , index = True )
2025-11-03 19:59:18 +08:00
status = CharField ( max_length = 1 , null = False , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . llm_name
class Meta :
db_table = " tenant_llm "
2026-03-05 17:27:17 +08:00
indexes = (
( ( " tenant_id " , " llm_factory " , " llm_name " ) , True ) ,
)
2025-03-24 13:18:47 +08:00
class TenantLangfuse ( DataBaseModel ) :
tenant_id = CharField ( max_length = 32 , null = False , primary_key = True )
secret_key = CharField ( max_length = 2048 , null = False , help_text = " SECRET KEY " , index = True )
public_key = CharField ( max_length = 2048 , null = False , help_text = " PUBLIC KEY " , index = True )
2025-03-24 18:25:43 +08:00
host = CharField ( max_length = 128 , null = False , help_text = " HOST " , index = True )
2025-03-24 13:18:47 +08:00
def __str__ ( self ) :
return " Langfuse host " + self . host
class Meta :
db_table = " tenant_langfuse "
2024-08-15 09:17:36 +08:00
class Knowledgebase ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 128 , null = False , help_text = " KB name " , index = True )
language = CharField ( max_length = 32 , null = True , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , help_text = " English|Chinese " , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " KB description " )
2025-03-24 13:18:47 +08:00
embd_id = CharField ( max_length = 128 , null = False , help_text = " default embedding model ID " , index = True )
2026-03-05 17:27:17 +08:00
tenant_embd_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-03-24 13:18:47 +08:00
permission = CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True )
2024-08-15 09:17:36 +08:00
created_by = CharField ( max_length = 32 , null = False , index = True )
doc_num = IntegerField ( default = 0 , index = True )
token_num = IntegerField ( default = 0 , index = True )
chunk_num = IntegerField ( default = 0 , index = True )
similarity_threshold = FloatField ( default = 0.2 , index = True )
vector_similarity_weight = FloatField ( default = 0.3 , index = True )
2025-03-24 13:18:47 +08:00
parser_id = CharField ( max_length = 32 , null = False , help_text = " default parser ID " , default = ParserType . NAIVE . value , index = True )
2025-10-09 12:36:19 +08:00
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True )
2025-11-27 10:21:44 +08:00
parser_config = JSONField ( null = False , default = { " pages " : [ [ 1 , 1000000 ] ] , " table_context_size " : 0 , " image_context_size " : 0 } )
2024-12-03 14:30:35 +08:00
pagerank = IntegerField ( default = 0 , index = False )
2025-10-09 12:36:19 +08:00
graphrag_task_id = CharField ( max_length = 32 , null = True , help_text = " Graph RAG task ID " , index = True )
graphrag_task_finish_at = DateTimeField ( null = True )
raptor_task_id = CharField ( max_length = 32 , null = True , help_text = " RAPTOR task ID " , index = True )
raptor_task_finish_at = DateTimeField ( null = True )
mindmap_task_id = CharField ( max_length = 32 , null = True , help_text = " Mindmap task ID " , index = True )
mindmap_task_finish_at = DateTimeField ( null = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " knowledgebase "
class Document ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
thumbnail = TextField ( null = True , help_text = " thumbnail base64 string " )
kb_id = CharField ( max_length = 256 , null = False , index = True )
2025-03-24 13:18:47 +08:00
parser_id = CharField ( max_length = 32 , null = False , help_text = " default parser ID " , index = True )
2025-11-16 19:29:20 +08:00
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " pipeline ID " , index = True )
2025-11-27 10:21:44 +08:00
parser_config = JSONField ( null = False , default = { " pages " : [ [ 1 , 1000000 ] ] , " table_context_size " : 0 , " image_context_size " : 0 } )
2025-03-24 13:18:47 +08:00
source_type = CharField ( max_length = 128 , null = False , default = " local " , help_text = " where dose this document come from " , index = True )
type = CharField ( max_length = 32 , null = False , help_text = " file extension " , index = True )
created_by = CharField ( max_length = 32 , null = False , help_text = " who created it " , index = True )
name = CharField ( max_length = 255 , null = True , help_text = " file name " , index = True )
location = CharField ( max_length = 255 , null = True , help_text = " where dose it store " , index = True )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
size = BigIntegerField ( default = 0 , index = True )
2024-08-15 09:17:36 +08:00
token_num = IntegerField ( default = 0 , index = True )
chunk_num = IntegerField ( default = 0 , index = True )
progress = FloatField ( default = 0 , index = True )
2025-03-24 13:18:47 +08:00
progress_msg = TextField ( null = True , help_text = " process message " , default = " " )
2024-08-15 09:17:36 +08:00
process_begin_at = DateTimeField ( null = True , index = True )
2025-07-07 14:11:47 +08:00
process_duration = FloatField ( default = 0 )
2025-07-09 09:33:11 +08:00
suffix = CharField ( max_length = 32 , null = False , help_text = " The real file extension suffix " , index = True )
2024-08-15 09:17:36 +08:00
fix: re-chunk documents when data source content is updated (#12918)
Closes: #12889
### What problem does this PR solve?
When syncing external data sources (e.g., Jira, Confluence, Google
Drive), updated documents were not being re-chunked. The raw content was
correctly updated in blob storage, but the vector database retained
stale chunks, causing search results to return outdated information.
**Root cause:** The task digest used for chunk reuse optimization was
calculated only from parser configuration fields (`parser_id`,
`parser_config`, `kb_id`, etc.), without any content-dependent fields.
When a document's content changed but the parser configuration remained
the same, the system incorrectly reused old chunks instead of
regenerating new ones.
**Example scenario:**
1. User syncs a Jira issue: "Meeting scheduled for Monday"
2. User updates the Jira issue to: "Meeting rescheduled to Friday"
3. User triggers sync again
4. Raw content panel shows updated text ✓
5. Chunk panel still shows old text "Monday" ✗
**Solution:**
1. Include `update_time` and `size` in the chunking config, so the task
digest changes when document content is updated
2. Track updated documents separately in `upload_document()` and return
them for processing
3. Process updated documents through the re-parsing pipeline to
regenerate chunks
[1.webm](https://github.com/user-attachments/assets/d21d4dcd-e189-4d39-8700-053bae0ca5a0)
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
2026-03-06 06:48:47 +02:00
content_hash = CharField ( max_length = 32 , null = True , help_text = " xxhash128 of document content for change detection " , default = " " , index = True )
2025-03-24 13:18:47 +08:00
run = CharField ( max_length = 1 , null = True , help_text = " start to run processing or cancel.(1: run it; 2: cancel) " , default = " 0 " , index = True )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " document "
class File ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
id = CharField ( max_length = 32 , primary_key = True )
parent_id = CharField ( max_length = 32 , null = False , help_text = " parent folder id " , index = True )
tenant_id = CharField ( max_length = 32 , null = False , help_text = " tenant id " , index = True )
created_by = CharField ( max_length = 32 , null = False , help_text = " who created it " , index = True )
name = CharField ( max_length = 255 , null = False , help_text = " file name or folder name " , index = True )
location = CharField ( max_length = 255 , null = True , help_text = " where dose it store " , index = True )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
size = BigIntegerField ( default = 0 , index = True )
2024-08-15 09:17:36 +08:00
type = CharField ( max_length = 32 , null = False , help_text = " file extension " , index = True )
2025-03-24 13:18:47 +08:00
source_type = CharField ( max_length = 128 , null = False , default = " " , help_text = " where dose this document come from " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " file "
class File2Document ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
id = CharField ( max_length = 32 , primary_key = True )
file_id = CharField ( max_length = 32 , null = True , help_text = " file id " , index = True )
document_id = CharField ( max_length = 32 , null = True , help_text = " document id " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " file2document "
2026-06-15 11:19:56 +08:00
class FileCommit ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
folder_id = CharField ( max_length = 32 , null = False , help_text = " workspace folder id " , index = True )
parent_id = CharField ( max_length = 32 , null = True , help_text = " parent commit id " , index = True )
message = CharField ( max_length = 512 , default = " " , help_text = " commit message " )
author_id = CharField ( max_length = 32 , null = False , help_text = " user who created the commit " , index = True )
file_count = IntegerField ( default = 0 , help_text = " number of files in this commit " )
tree_state = LongTextField ( null = True , help_text = " JSON snapshot of the full folder tree at this commit " )
class Meta :
db_table = " file_commit "
class FileCommitItem ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
commit_id = CharField ( max_length = 32 , null = False , help_text = " commit id " , index = True )
file_id = CharField ( max_length = 32 , null = False , help_text = " file id " , index = True )
operation = CharField ( max_length = 16 , null = False , help_text = " add / modify / delete / rename " , index = True )
old_hash = CharField ( max_length = 64 , null = True , help_text = " old content hash " , index = True )
new_hash = CharField ( max_length = 64 , null = True , help_text = " new content hash " , index = True )
old_location = CharField ( max_length = 255 , null = True , help_text = " old storage location " )
new_location = CharField ( max_length = 255 , null = True , help_text = " new storage location " )
old_name = CharField ( max_length = 255 , null = True , help_text = " old file name (for rename) " )
new_name = CharField ( max_length = 255 , null = True , help_text = " new file name (for rename) " )
class Meta :
db_table = " file_commit_item "
indexes = (
( ( " commit_id " , " file_id " ) , True ) , # unique composite index
)
2024-08-15 09:17:36 +08:00
class Task ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
doc_id = CharField ( max_length = 32 , null = False , index = True )
from_page = IntegerField ( default = 0 )
Fix: Remove hardcoded page limits causing parsing failures on large PDFs (>300 pages) (#14382)
### What problem does this PR solve?
Fixes #14196
## Problem
When using DeepDOC to parse large PDFs (over 1000 pages), the parser
silently truncated processing at 300 pages due to a hardcoded default
`page_to=299` in `RAGFlowPdfParser.__images__()`. This caused:
- **Errors** on pages beyond the limit
- **Poor image quality** as the parser attempted to compensate with
missing page data
- **Inconsistent chunk splitting** between full PDF imports and partial
imports
Additionally, the codebase scattered magic numbers (`299`, `600`,
`10000`, `100000`, `100000000`, `10000000000`, `10**9`) across 22 files
as sentinel values for "parse all pages", making future maintenance
error-prone.
## Root Cause
```python
# deepdoc/parser/pdf_parser.py (before)
def __images__(self, fnm, zoomin=3, page_from=0, page_to=299, callback=None):
# Only the first 300 pages were rendered; everything beyond was silently dropped
```
While most callers in `rag/app/*.py` correctly passed `to_page=100000`,
the base class `RAGFlowPdfParser.__call__()` and `parse_into_bboxes()`
invoked `__images__` **without** forwarding `page_from`/`page_to`,
falling back to the restrictive default of 299.
## Solution
### 1. Define constants in `common/constants.py`
```python
MAXIMUM_PAGE_NUMBER = 100000 # Used by the parsing layer
MAXIMUM_TASK_PAGE_NUMBER = MAXIMUM_PAGE_NUMBER * 1000 # Used by the task/DB layer
```
### 2. Replace all hardcoded sentinel values
| Layer | Files Changed | Old Values | New Value |
|---|---|---|---|
| **Deepdoc parsers** | `pdf_parser.py`, `mineru_parser.py`,
`docling_parser.py`, `opendataloader_parser.py`, `paddleocr_parser.py`,
`docx_parser.py` | `299`, `600`, `10**9`, `100000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Chunk parsers** | `naive.py`, `book.py`, `qa.py`, `one.py`,
`manual.py`, `paper.py`, `presentation.py`, `laws.py`, `resume.py`,
`email.py`, `table.py` | `100000`, `10000`, `10000000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Task/DB layer** | `db_models.py`, `task_service.py`,
`document_service.py`, `file_service.py` | `100000000` |
`MAXIMUM_TASK_PAGE_NUMBER` |
### 3. Fix `parse_into_bboxes()` missing parameters
Added `from_page`/`to_page` parameters to `parse_into_bboxes()` so that
the `rag/flow/parser/parser.py` DeepDOC path no longer falls back to the
restrictive default.
## Files Changed (22)
- `common/constants.py`
- `deepdoc/parser/pdf_parser.py`
- `deepdoc/parser/mineru_parser.py`
- `deepdoc/parser/docling_parser.py`
- `deepdoc/parser/opendataloader_parser.py`
- `deepdoc/parser/paddleocr_parser.py`
- `deepdoc/parser/docx_parser.py`
- `rag/app/naive.py`
- `rag/app/book.py`
- `rag/app/qa.py`
- `rag/app/one.py`
- `rag/app/manual.py`
- `rag/app/paper.py`
- `rag/app/presentation.py`
- `rag/app/laws.py`
- `rag/app/resume.py`
- `rag/app/email.py`
- `rag/app/table.py`
- `api/db/db_models.py`
- `api/db/services/task_service.py`
- `api/db/services/document_service.py`
- `api/db/services/file_service.py`
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
---------
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-27 06:57:20 +00:00
to_page = IntegerField ( default = MAXIMUM_TASK_PAGE_NUMBER )
2025-03-05 14:48:03 +08:00
task_type = CharField ( max_length = 32 , null = False , default = " " )
2025-03-14 23:43:46 +08:00
priority = IntegerField ( default = 0 )
2024-08-15 09:17:36 +08:00
begin_at = DateTimeField ( null = True , index = True )
2025-07-07 14:11:47 +08:00
process_duration = FloatField ( default = 0 )
2024-08-15 09:17:36 +08:00
progress = FloatField ( default = 0 , index = True )
2025-03-24 13:18:47 +08:00
progress_msg = TextField ( null = True , help_text = " process message " , default = " " )
2024-08-29 13:31:41 +08:00
retry_count = IntegerField ( default = 0 )
2024-12-12 16:38:03 +08:00
digest = TextField ( null = True , help_text = " task digest " , default = " " )
chunk_ids = LongTextField ( null = True , help_text = " chunk ids " , default = " " )
2024-08-15 09:17:36 +08:00
class Dialog ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " dialog application name " , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " Dialog description " )
icon = TextField ( null = True , help_text = " icon base64 string " )
2025-03-24 13:18:47 +08:00
language = CharField ( max_length = 32 , null = True , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , help_text = " English|Chinese " , index = True )
2024-08-15 09:17:36 +08:00
llm_id = CharField ( max_length = 128 , null = False , help_text = " default llm ID " )
2026-03-05 17:27:17 +08:00
tenant_llm_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2024-08-15 09:17:36 +08:00
2025-03-24 13:18:47 +08:00
llm_setting = JSONField ( null = False , default = { " temperature " : 0.1 , " top_p " : 0.3 , " frequency_penalty " : 0.7 , " presence_penalty " : 0.4 , " max_tokens " : 512 } )
prompt_type = CharField ( max_length = 16 , null = False , default = " simple " , help_text = " simple|advanced " , index = True )
prompt_config = JSONField (
2024-08-15 09:17:36 +08:00
null = False ,
2025-08-14 12:13:11 +08:00
default = { " system " : " " , " prologue " : " Hi! I ' m your assistant. What can I do for you? " , " parameters " : [ ] , " empty_response " : " Sorry! No relevant content was found in the knowledge base! " } ,
2025-03-24 13:18:47 +08:00
)
2025-08-12 14:12:56 +08:00
meta_data_filter = JSONField ( null = True , default = { } )
2024-08-15 09:17:36 +08:00
similarity_threshold = FloatField ( default = 0.2 )
vector_similarity_weight = FloatField ( default = 0.3 )
top_n = IntegerField ( default = 6 )
top_k = IntegerField ( default = 1024 )
2025-03-24 13:18:47 +08:00
do_refer = CharField ( max_length = 1 , null = False , default = " 1 " , help_text = " it needs to insert reference index into answer or not " )
2024-11-19 14:51:33 +08:00
2025-03-24 13:18:47 +08:00
rerank_id = CharField ( max_length = 128 , null = False , help_text = " default rerank model ID " )
2026-03-05 17:27:17 +08:00
tenant_rerank_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2024-08-15 09:17:36 +08:00
kb_ids = JSONField ( null = False , default = [ ] )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " dialog "
class Conversation ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
dialog_id = CharField ( max_length = 32 , null = False , index = True )
2025-11-16 19:29:20 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = True )
2024-08-15 09:17:36 +08:00
message = JSONField ( null = True )
reference = JSONField ( null = True , default = [ ] )
2024-12-24 15:59:11 +08:00
user_id = CharField ( max_length = 255 , null = True , help_text = " user_id " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " conversation "
class APIToken ( DataBaseModel ) :
tenant_id = CharField ( max_length = 32 , null = False , index = True )
token = CharField ( max_length = 255 , null = False , index = True )
2025-03-07 13:26:08 +08:00
dialog_id = CharField ( max_length = 32 , null = True , index = True )
2024-08-15 09:17:36 +08:00
source = CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True )
2024-12-09 12:38:04 +08:00
beta = CharField ( max_length = 255 , null = True , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " api_token "
2025-03-24 13:18:47 +08:00
primary_key = CompositeKey ( " tenant_id " , " token " )
2024-08-15 09:17:36 +08:00
class API4Conversation ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
2026-02-05 19:19:09 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = False )
2024-08-15 09:17:36 +08:00
dialog_id = CharField ( max_length = 32 , null = False , index = True )
user_id = CharField ( max_length = 255 , null = False , help_text = " user_id " , index = True )
2026-02-05 19:19:09 +08:00
exp_user_id = CharField ( max_length = 255 , null = True , help_text = " exp_user_id " , index = True )
2024-08-15 09:17:36 +08:00
message = JSONField ( null = True )
reference = JSONField ( null = True , default = [ ] )
tokens = IntegerField ( default = 0 )
source = CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True )
2024-12-02 19:05:18 +08:00
dsl = JSONField ( null = True , default = { } )
2024-08-15 09:17:36 +08:00
duration = FloatField ( default = 0 , index = True )
round = IntegerField ( default = 0 , index = True )
thumb_up = IntegerField ( default = 0 , index = True )
2025-07-30 19:41:09 +08:00
errors = TextField ( null = True , help_text = " errors " )
2026-03-17 18:51:26 +08:00
version_title = CharField ( max_length = 255 , null = True , help_text = " canvas version title when session created " , index = False )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " api_4_conversation "
class UserCanvas ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
user_id = CharField ( max_length = 255 , null = False , help_text = " user_id " , index = True )
title = CharField ( max_length = 255 , null = True , help_text = " Canvas title " )
2025-03-24 13:18:47 +08:00
permission = CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True )
2026-03-05 17:26:39 +08:00
release = BooleanField ( null = False , help_text = " is released " , default = False , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " Canvas description " )
canvas_type = CharField ( max_length = 32 , null = True , help_text = " Canvas type " , index = True )
2025-09-03 14:55:24 +08:00
canvas_category = CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " Canvas category: agent_canvas|dataflow_canvas " , index = True )
feat: add tag management for Agents with filtering and sorting (#14774) (#14799)
## Summary
Closes #14774.
Adds free-form tags on agents (UserCanvas) with full UI + API:
- Stored as comma-separated `tags` column on `UserCanvas` with online
migration.
- New endpoints: `GET /v1/agents/tags` (aggregate counts) and `PUT
/v1/agent/<id>/tags` (write). `GET /v1/agents` accepts a `tags=` query.
- "Edit tags" item in agent dropdown opens a chip-style editor dialog;
tags render as badges on each agent card.
- New "Tags" facet in the agents filter bar, with counts.
## Implementation notes
- **Tag matching is exact-token**: the SQL filter wraps stored tags as
`,…,` and matches `,ml,` so `ml` doesn't match `ml-ops`.
- **Server-side normalization** in `UserCanvasService.update_tags`:
dedup (case-insensitive), per-tag cap of 64 chars, total length capped
at 512 chars to fit the column, commas inside tag values are replaced
with spaces.
- **Tenant authorization**: `PUT /v1/agent/<id>/tags` gates on
`UserCanvasService.accessible(canvas_id, tenant_id)`.
- **Tag listing scope**: `UserCanvasService.list_tags` follows the same
own + team-shared rule as `get_by_tenant_ids`.
- **i18n**: keys added to `en.ts` and `zh.ts` only (per project
convention; other locales fall back).
- **`HomeCard`** gets a non-breaking `extra?: ReactNode` slot for the
chip row; no `src/components/ui/` files modified.
## Test plan
- [ ] Backend boot runs `migrate_db` → confirm `user_canvas.tags` column
exists (`DESCRIBE user_canvas`).
- [ ] Agents page renders cards normally (no console error from missing
field).
- [ ] `⋯ → Edit tags` opens a dialog that stays open (regression: dialog
was unmounting with the dropdown).
- [ ] Typing a tag without pressing Enter and clicking Save persists it
(regression: last typed tag was being dropped).
- [ ] Chip input supports Enter/comma to commit, Backspace on empty to
remove, `×` to remove individual chip.
- [ ] Tag containing a comma sent via API is stored with the comma
replaced by a space.
- [ ] 20 long tags sent via API does not error (length cap silently
truncates).
- [ ] "Tags" filter in the filter bar shows counts and narrows the list.
- [ ] Filtering by `ml` does **not** return agents tagged `ml-ops`.
- [ ] UI in Chinese shows 编辑标签 / 添加标签以整理和筛选你的智能体 etc.
- [ ] `PUT /v1/agent/<other-tenant-id>/tags` returns `Agent not found or
no permission.`
2026-05-13 06:41:32 -07:00
tags = CharField ( max_length = 512 , null = False , default = " " , help_text = " Comma-separated tags for organizing agents " , index = True )
2024-08-15 09:17:36 +08:00
dsl = JSONField ( null = True , default = { } )
2025-03-24 13:18:47 +08:00
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user_canvas "
class CanvasTemplate ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
2025-08-28 09:34:47 +08:00
title = JSONField ( null = True , default = dict , help_text = " Canvas title " )
description = JSONField ( null = True , default = dict , help_text = " Canvas description " )
2024-08-15 09:17:36 +08:00
canvas_type = CharField ( max_length = 32 , null = True , help_text = " Canvas type " , index = True )
2026-04-13 09:26:30 -03:00
canvas_types = ListField ( null = True , default = list , help_text = " Canvas types " )
2025-09-03 14:55:24 +08:00
canvas_category = CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " Canvas category: agent_canvas|dataflow_canvas " , index = True )
2024-08-15 09:17:36 +08:00
dsl = JSONField ( null = True , default = { } )
class Meta :
db_table = " canvas_template "
2025-03-24 13:18:47 +08:00
2025-03-19 14:22:53 +07:00
class UserCanvasVersion ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
user_canvas_id = CharField ( max_length = 255 , null = False , help_text = " user_canvas_id " , index = True )
title = CharField ( max_length = 255 , null = True , help_text = " Canvas title " )
description = TextField ( null = True , help_text = " Canvas description " )
2026-03-10 14:25:27 +08:00
release = BooleanField ( null = False , help_text = " is released " , default = False , index = True )
2025-03-19 14:22:53 +07:00
dsl = JSONField ( null = True , default = { } )
class Meta :
db_table = " user_canvas_version "
2024-08-15 09:17:36 +08:00
2025-03-24 13:18:47 +08:00
2025-06-23 17:45:35 +08:00
class MCPServer ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 255 , null = False , help_text = " MCP Server name " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
url = CharField ( max_length = 2048 , null = False , help_text = " MCP Server URL " )
server_type = CharField ( max_length = 32 , null = False , help_text = " MCP Server type " )
description = TextField ( null = True , help_text = " MCP Server description " )
2025-06-25 09:26:04 +08:00
variables = JSONField ( null = True , default = dict , help_text = " MCP Server variables " )
headers = JSONField ( null = True , default = dict , help_text = " MCP Server additional request headers " )
2025-06-23 17:45:35 +08:00
class Meta :
db_table = " mcp_server "
2025-06-25 09:26:04 +08:00
2025-06-18 16:45:42 +08:00
class Search ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Search name " , index = True )
description = TextField ( null = True , help_text = " KB description " )
created_by = CharField ( max_length = 32 , null = False , index = True )
search_config = JSONField (
null = False ,
default = {
" kb_ids " : [ ] ,
" doc_ids " : [ ] ,
2025-08-19 09:33:33 +08:00
" similarity_threshold " : 0.2 ,
2025-06-18 16:45:42 +08:00
" vector_similarity_weight " : 0.3 ,
" use_kg " : False ,
# rerank settings
" rerank_id " : " " ,
" top_k " : 1024 ,
# chat settings
" summary " : False ,
" chat_id " : " " ,
2025-08-15 17:44:58 +08:00
# Leave it here for reference, don't need to set default values
2025-06-18 16:45:42 +08:00
" llm_setting " : {
2025-08-15 17:44:58 +08:00
# "temperature": 0.1,
# "top_p": 0.3,
# "frequency_penalty": 0.7,
# "presence_penalty": 0.4,
2025-06-18 16:45:42 +08:00
} ,
" chat_settingcross_languages " : [ ] ,
" highlight " : False ,
" keyword " : False ,
" web_search " : False ,
" related_search " : False ,
" query_mindmap " : False ,
} ,
)
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
def __str__ ( self ) :
return self . name
class Meta :
db_table = " search "
2025-10-09 12:36:19 +08:00
class PipelineOperationLog ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
document_id = CharField ( max_length = 32 , index = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True )
pipeline_title = CharField ( max_length = 32 , null = True , help_text = " Pipeline title " , index = True )
parser_id = CharField ( max_length = 32 , null = False , help_text = " Parser ID " , index = True )
document_name = CharField ( max_length = 255 , null = False , help_text = " File name " )
document_suffix = CharField ( max_length = 255 , null = False , help_text = " File suffix " )
document_type = CharField ( max_length = 255 , null = False , help_text = " Document type " )
source_from = CharField ( max_length = 255 , null = False , help_text = " Source " )
progress = FloatField ( default = 0 , index = True )
progress_msg = TextField ( null = True , help_text = " process message " , default = " " )
process_begin_at = DateTimeField ( null = True , index = True )
process_duration = FloatField ( default = 0 )
dsl = JSONField ( null = True , default = dict )
task_type = CharField ( max_length = 32 , null = False , default = " " )
operation_status = CharField ( max_length = 32 , null = False , help_text = " Operation status " )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
class Meta :
db_table = " pipeline_operation_log "
2025-11-03 19:59:18 +08:00
class Connector ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Search name " , index = False )
source = CharField ( max_length = 128 , null = False , help_text = " Data source " , index = True )
input_type = CharField ( max_length = 128 , null = False , help_text = " poll/event/.. " , index = True )
config = JSONField ( null = False , default = { } )
refresh_freq = IntegerField ( default = 0 , index = False )
prune_freq = IntegerField ( default = 0 , index = False )
timeout_secs = IntegerField ( default = 3600 , index = False )
indexing_start = DateTimeField ( null = True , index = True )
status = CharField ( max_length = 16 , null = True , help_text = " schedule " , default = " schedule " , index = True )
def __str__ ( self ) :
return self . name
class Meta :
db_table = " connector "
class Connector2Kb ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
connector_id = CharField ( max_length = 32 , null = False , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
2025-11-07 11:43:59 +08:00
auto_parse = CharField ( max_length = 1 , null = False , default = " 1 " , index = False )
2025-11-03 19:59:18 +08:00
class Meta :
db_table = " connector2kb "
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
class ChatChannel ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Bot name " , index = False )
channel = CharField ( max_length = 128 , null = False , help_text = " Chat channel type " , index = True )
config = JSONField ( null = False , default = { } , help_text = " Channel credential & settings " )
2026-06-16 19:02:20 +08:00
chat_id = CharField ( max_length = 32 , null = True , default = None , help_text = " connected chat id " , index = True )
2026-06-16 12:02:12 +08:00
status = IntegerField ( default = 1 , index = True )
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " chat_channel "
2025-11-03 19:59:18 +08:00
class DateTimeTzField ( CharField ) :
field_type = ' VARCHAR '
def db_value ( self , value : datetime | None ) - > str | None :
if value is not None :
if value . tzinfo is not None :
return value . isoformat ( )
else :
return value . replace ( tzinfo = timezone . utc ) . isoformat ( )
return value
def python_value ( self , value : str | None ) - > datetime | None :
if value is not None :
dt = datetime . fromisoformat ( value )
if dt . tzinfo is None :
import pytz
return dt . replace ( tzinfo = pytz . UTC )
return dt
return value
class SyncLogs ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
connector_id = CharField ( max_length = 32 , index = True )
2026-05-19 10:07:11 +08:00
task_type = CharField ( max_length = 32 , null = False , default = " sync " , index = True )
2025-11-03 19:59:18 +08:00
status = CharField ( max_length = 128 , null = False , help_text = " Processing status " , index = True )
from_beginning = CharField ( max_length = 1 , null = True , help_text = " " , default = " 0 " , index = False )
new_docs_indexed = IntegerField ( default = 0 , index = False )
total_docs_indexed = IntegerField ( default = 0 , index = False )
docs_removed_from_index = IntegerField ( default = 0 , index = False )
error_msg = TextField ( null = False , help_text = " process message " , default = " " )
error_count = IntegerField ( default = 0 , index = False )
full_exception_trace = TextField ( null = True , help_text = " process message " , default = " " )
time_started = DateTimeField ( null = True , index = True )
poll_range_start = DateTimeTzField ( max_length = 255 , null = True , index = True )
poll_range_end = DateTimeTzField ( max_length = 255 , null = True , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
class Meta :
db_table = " sync_logs "
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
class EvaluationDataset ( DataBaseModel ) :
""" Ground truth dataset for RAG evaluation """
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True , help_text = " tenant ID " )
name = CharField ( max_length = 255 , null = False , index = True , help_text = " dataset name " )
description = TextField ( null = True , help_text = " dataset description " )
kb_ids = JSONField ( null = False , help_text = " knowledge base IDs to evaluate against " )
created_by = CharField ( max_length = 32 , null = False , index = True , help_text = " creator user ID " )
create_time = BigIntegerField ( null = False , index = True , help_text = " creation timestamp " )
update_time = BigIntegerField ( null = False , help_text = " last update timestamp " )
status = IntegerField ( null = False , default = 1 , help_text = " 1=valid, 0=invalid " )
class Meta :
db_table = " evaluation_datasets "
class EvaluationCase ( DataBaseModel ) :
""" Individual test case in an evaluation dataset """
id = CharField ( max_length = 32 , primary_key = True )
dataset_id = CharField ( max_length = 32 , null = False , index = True , help_text = " FK to evaluation_datasets " )
question = TextField ( null = False , help_text = " test question " )
reference_answer = TextField ( null = True , help_text = " optional ground truth answer " )
relevant_doc_ids = JSONField ( null = True , help_text = " expected relevant document IDs " )
relevant_chunk_ids = JSONField ( null = True , help_text = " expected relevant chunk IDs " )
metadata = JSONField ( null = True , help_text = " additional context/tags " )
create_time = BigIntegerField ( null = False , help_text = " creation timestamp " )
class Meta :
db_table = " evaluation_cases "
class EvaluationRun ( DataBaseModel ) :
""" A single evaluation run """
id = CharField ( max_length = 32 , primary_key = True )
dataset_id = CharField ( max_length = 32 , null = False , index = True , help_text = " FK to evaluation_datasets " )
dialog_id = CharField ( max_length = 32 , null = False , index = True , help_text = " dialog configuration being evaluated " )
name = CharField ( max_length = 255 , null = False , help_text = " run name " )
config_snapshot = JSONField ( null = False , help_text = " dialog config at time of evaluation " )
metrics_summary = JSONField ( null = True , help_text = " aggregated metrics " )
status = CharField ( max_length = 32 , null = False , default = " PENDING " , help_text = " PENDING/RUNNING/COMPLETED/FAILED " )
created_by = CharField ( max_length = 32 , null = False , index = True , help_text = " user who started the run " )
create_time = BigIntegerField ( null = False , index = True , help_text = " creation timestamp " )
complete_time = BigIntegerField ( null = True , help_text = " completion timestamp " )
class Meta :
db_table = " evaluation_runs "
class EvaluationResult ( DataBaseModel ) :
""" Result for a single test case in an evaluation run """
id = CharField ( max_length = 32 , primary_key = True )
run_id = CharField ( max_length = 32 , null = False , index = True , help_text = " FK to evaluation_runs " )
case_id = CharField ( max_length = 32 , null = False , index = True , help_text = " FK to evaluation_cases " )
generated_answer = TextField ( null = False , help_text = " generated answer " )
retrieved_chunks = JSONField ( null = False , help_text = " chunks that were retrieved " )
metrics = JSONField ( null = False , help_text = " all computed metrics " )
execution_time = FloatField ( null = False , help_text = " response time in seconds " )
token_usage = JSONField ( null = True , help_text = " prompt/completion tokens " )
create_time = BigIntegerField ( null = False , help_text = " creation timestamp " )
class Meta :
db_table = " evaluation_results "
2025-12-10 13:34:08 +08:00
class Memory ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 128 , null = False , index = False , help_text = " Memory name " )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
memory_type = IntegerField ( null = False , default = 1 , index = True , help_text = " Bit flags (LSB->MSB): 1=raw, 2=semantic, 4=episodic, 8=procedural. E.g., 5 enables raw + episodic. " )
storage_type = CharField ( max_length = 32 , default = ' table ' , null = False , index = True , help_text = " table|graph " )
embd_id = CharField ( max_length = 128 , null = False , index = False , help_text = " embedding model ID " )
2026-03-05 17:27:17 +08:00
tenant_embd_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-12-10 13:34:08 +08:00
llm_id = CharField ( max_length = 128 , null = False , index = False , help_text = " chat model ID " )
2026-03-05 17:27:17 +08:00
tenant_llm_id = IntegerField ( null = True , help_text = " id in tenant_llm " , index = True )
2025-12-10 13:34:08 +08:00
permissions = CharField ( max_length = 16 , null = False , index = True , help_text = " me|team " , default = " me " )
description = TextField ( null = True , help_text = " description " )
memory_size = IntegerField ( default = 5242880 , null = False , index = False )
2025-12-26 11:18:08 +08:00
forgetting_policy = CharField ( max_length = 32 , null = False , default = " FIFO " , index = False , help_text = " LRU|FIFO " )
2025-12-10 13:34:08 +08:00
temperature = FloatField ( default = 0.5 , index = False )
system_prompt = TextField ( null = True , help_text = " system prompt " , index = False )
user_prompt = TextField ( null = True , help_text = " user prompt " , index = False )
class Meta :
db_table = " memory "
2026-01-04 14:21:39 +08:00
class SystemSettings ( DataBaseModel ) :
name = CharField ( max_length = 128 , primary_key = True )
2026-01-04 20:26:12 +08:00
source = CharField ( max_length = 32 , null = False , index = False )
2026-01-04 14:21:39 +08:00
data_type = CharField ( max_length = 32 , null = False , index = False )
feat: Implement pluggable multi-provider sandbox architecture (#12820)
## Summary
Implement a flexible sandbox provider system supporting both
self-managed (Docker) and SaaS (Aliyun Code Interpreter) backends for
secure code execution in agent workflows.
**Key Changes:**
- ✅ Aliyun Code Interpreter provider using official
`agentrun-sdk>=0.0.16`
- ✅ Self-managed provider with gVisor (runsc) security
- ✅ Arguments parameter support for dynamic code execution
- ✅ Database-only configuration (removed fallback logic)
- ✅ Configuration scripts for quick setup
Issue #12479
## Features
### 🔌 Provider Abstraction Layer
**1. Self-Managed Provider** (`agent/sandbox/providers/self_managed.py`)
- Wraps existing executor_manager HTTP API
- gVisor (runsc) for secure container isolation
- Configurable pool size, timeout, retry logic
- Languages: Python, Node.js, JavaScript
- ⚠️ **Requires**: gVisor installation, Docker, base images
**2. Aliyun Code Interpreter**
(`agent/sandbox/providers/aliyun_codeinterpreter.py`)
- SaaS integration using official agentrun-sdk
- Serverless microVM execution with auto-authentication
- Hard timeout: 30 seconds max
- Credentials: `AGENTRUN_ACCESS_KEY_ID`, `AGENTRUN_ACCESS_KEY_SECRET`,
`AGENTRUN_ACCOUNT_ID`, `AGENTRUN_REGION`
- Automatically wraps code to call `main()` function
**3. E2B Provider** (`agent/sandbox/providers/e2b.py`)
- Placeholder for future integration
### ⚙️ Configuration System
- `conf/system_settings.json`: Default provider =
`aliyun_codeinterpreter`
- `agent/sandbox/client.py`: Enforces database-only configuration
- Admin UI: `/admin/sandbox-settings`
- Configuration validation via `validate_config()` method
- Health checks for all providers
### 🎯 Key Capabilities
**Arguments Parameter Support:**
All providers support passing arguments to `main()` function:
```python
# User code
def main(name: str, count: int) -> dict:
return {"message": f"Hello {name}!" * count}
# Executed with: arguments={"name": "World", "count": 3}
# Result: {"message": "Hello World!Hello World!Hello World!"}
```
**Self-Describing Providers:**
Each provider implements `get_config_schema()` returning form
configuration for Admin UI
**Error Handling:**
Structured `ExecutionResult` with stdout, stderr, exit_code,
execution_time
## Configuration Scripts
Two scripts for quick Aliyun sandbox setup:
**Shell Script (requires jq):**
```bash
source scripts/configure_aliyun_sandbox.sh
```
**Python Script (interactive):**
```bash
python3 scripts/configure_aliyun_sandbox.py
```
## Testing
```bash
# Unit tests
uv run pytest agent/sandbox/tests/test_providers.py -v
# Aliyun provider tests
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter.py -v
# Integration tests (requires credentials)
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py -v
# Quick SDK validation
python3 agent/sandbox/tests/verify_sdk.py
```
**Test Coverage:**
- 30 unit tests for provider abstraction
- Provider-specific tests for Aliyun
- Integration tests with real API
- Security tests for executor_manager
## Documentation
- `docs/develop/sandbox_spec.md` - Complete architecture specification
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration from legacy
sandbox
- `agent/sandbox/tests/QUICKSTART.md` - Quick start guide
- `agent/sandbox/tests/README.md` - Testing documentation
## Breaking Changes
⚠️ **Migration Required:**
1. **Directory Move**: `sandbox/` → `agent/sandbox/`
- Update imports: `from sandbox.` → `from agent.sandbox.`
2. **Mandatory Configuration**:
- SystemSettings must have `sandbox.provider_type` configured
- Removed fallback default values
- Configuration must exist in database (from
`conf/system_settings.json`)
3. **Aliyun Credentials**:
- Requires `AGENTRUN_*` environment variables (not `ALIYUN_*`)
- `AGENTRUN_ACCOUNT_ID` is now required (Aliyun primary account ID)
4. **Self-Managed Provider**:
- gVisor (runsc) must be installed for security
- Install: `go install gvisor.dev/gvisor/runsc@latest`
## Database Schema Changes
```python
# SystemSettings.value: CharField → TextField
api/db/db_models.py: Changed for unlimited config length
# SystemSettingsService.get_by_name(): Fixed query precision
api/db/services/system_settings_service.py: startswith → exact match
```
## Files Changed
### Backend (Python)
- `agent/sandbox/providers/base.py` - SandboxProvider ABC interface
- `agent/sandbox/providers/manager.py` - ProviderManager
- `agent/sandbox/providers/self_managed.py` - Self-managed provider
- `agent/sandbox/providers/aliyun_codeinterpreter.py` - Aliyun provider
- `agent/sandbox/providers/e2b.py` - E2B provider (placeholder)
- `agent/sandbox/client.py` - Unified client (enforces DB-only config)
- `agent/tools/code_exec.py` - Updated to use provider system
- `admin/server/services.py` - SandboxMgr with registry & validation
- `admin/server/routes.py` - 5 sandbox API endpoints
- `conf/system_settings.json` - Default: aliyun_codeinterpreter
- `api/db/db_models.py` - TextField for SystemSettings.value
- `api/db/services/system_settings_service.py` - Exact match query
### Frontend (TypeScript/React)
- `web/src/pages/admin/sandbox-settings.tsx` - Settings UI
- `web/src/services/admin-service.ts` - Sandbox service functions
- `web/src/services/admin.service.d.ts` - Type definitions
- `web/src/utils/api.ts` - Sandbox API endpoints
### Documentation
- `docs/develop/sandbox_spec.md` - Architecture spec
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration guide
- `agent/sandbox/tests/QUICKSTART.md` - Quick start
- `agent/sandbox/tests/README.md` - Testing guide
### Configuration Scripts
- `scripts/configure_aliyun_sandbox.sh` - Shell script (jq)
- `scripts/configure_aliyun_sandbox.py` - Python script
### Tests
- `agent/sandbox/tests/test_providers.py` - 30 unit tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter.py` - Provider tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py` -
Integration tests
- `agent/sandbox/tests/verify_sdk.py` - SDK validation
## Architecture
```
Admin UI → Admin API → SandboxMgr → ProviderManager → [SelfManaged|Aliyun|E2B]
↓
SystemSettings
```
## Usage
### 1. Configure Provider
**Via Admin UI:**
1. Navigate to `/admin/sandbox-settings`
2. Select provider (Aliyun Code Interpreter / Self-Managed)
3. Fill in configuration
4. Click "Test Connection" to verify
5. Click "Save" to apply
**Via Configuration Scripts:**
```bash
# Aliyun provider
export AGENTRUN_ACCESS_KEY_ID="xxx"
export AGENTRUN_ACCESS_KEY_SECRET="yyy"
export AGENTRUN_ACCOUNT_ID="zzz"
export AGENTRUN_REGION="cn-shanghai"
source scripts/configure_aliyun_sandbox.sh
```
### 2. Restart Service
```bash
cd docker
docker compose restart ragflow-server
```
### 3. Execute Code in Agent
```python
from agent.sandbox.client import execute_code
result = execute_code(
code='def main(name: str) -> dict: return {"message": f"Hello {name}!"}',
language="python",
timeout=30,
arguments={"name": "World"}
)
print(result.stdout) # {"message": "Hello World!"}
```
## Troubleshooting
### "Container pool is busy" (Self-Managed)
- **Cause**: Pool exhausted (default: 1 container in `.env`)
- **Fix**: Increase `SANDBOX_EXECUTOR_MANAGER_POOL_SIZE` to 5+
### "Sandbox provider type not configured"
- **Cause**: Database missing configuration
- **Fix**: Run config script or set via Admin UI
### "gVisor not found"
- **Cause**: runsc not installed
- **Fix**: `go install gvisor.dev/gvisor/runsc@latest && sudo cp
~/go/bin/runsc /usr/local/bin/`
### Aliyun authentication errors
- **Cause**: Wrong environment variable names
- **Fix**: Use `AGENTRUN_*` prefix (not `ALIYUN_*`)
## Checklist
- [x] All tests passing (30 unit tests + integration tests)
- [x] Documentation updated (spec, migration guide, quickstart)
- [x] Type definitions added (TypeScript)
- [x] Admin UI implemented
- [x] Configuration validation
- [x] Health checks implemented
- [x] Error handling with structured results
- [x] Breaking changes documented
- [x] Configuration scripts created
- [x] gVisor requirements documented
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 13:28:21 +08:00
value = TextField ( null = False , help_text = " Configuration value (JSON, string, etc.) " )
2026-01-04 14:21:39 +08:00
class Meta :
db_table = " system_settings "
2025-12-10 13:34:08 +08:00
2026-05-29 17:39:41 +08:00
class TenantModelProvider ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
provider_name = CharField ( max_length = 128 , null = False , index = False , help_text = " LLM provider name " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
class Meta :
db_table = " tenant_model_provider "
indexes = (
( ( " tenant_id " , " provider_name " ) , True ) ,
)
class TenantModelInstance ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
instance_name = CharField ( max_length = 128 , null = False , index = False , help_text = " Model instance name " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
api_key = CharField ( max_length = 512 , null = False , index = False , help_text = " API key " )
status = CharField ( max_length = 32 , default = " active " , index = False )
extra = CharField ( max_length = 512 , default = " {} " , index = False )
class Meta :
db_table = " tenant_model_instance "
class TenantModel ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
model_name = CharField ( max_length = 128 , null = True , index = False , help_text = " Model name " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
instance_id = CharField ( max_length = 32 , null = False , index = True )
model_type = CharField ( max_length = 32 , null = False , index = False , help_text = " Model type " )
status = CharField ( max_length = 32 , default = " active " , index = False )
extra = CharField ( max_length = 1024 , default = " {} " , index = False )
class Meta :
db_table = " tenant_model "
class TenantModelGroup ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
group_type = CharField ( max_length = 32 , null = False , index = False , help_text = " Group type " )
model_name = CharField ( max_length = 128 , null = True , index = False , help_text = " Model name " )
strategy = CharField ( max_length = 32 , default = " weighted " , index = False , help_text = " Routing strategy " )
class Meta :
db_table = " tenant_model_group "
class TenantModelGroupMapping ( DataBaseModel ) :
group_id = CharField ( max_length = 32 , null = False , index = True , help_text = " Group ID " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
instance_id = CharField ( max_length = 32 , null = False , index = False )
model_id = CharField ( max_length = 32 , null = False , index = True )
weight = IntegerField ( default = 100 , index = False , help_text = " Routing weight " )
status = CharField ( max_length = 32 , default = " active " , index = False )
class Meta :
db_table = " tenant_model_group_mapping "
primary_key = CompositeKey ( " group_id " , " provider_id " , " instance_id " , " model_id " )
2026-01-08 16:44:53 +08:00
def alter_db_add_column ( migrator , table_name , column_name , column_type ) :
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . add_column ( table_name , column_name , column_type ) )
except OperationalError as ex :
error_codes = [ 1060 ]
error_messages = [ ' Duplicate column name ' ]
should_skip_error = (
( hasattr ( ex , ' args ' ) and ex . args and ex . args [ 0 ] in error_codes ) or
( str ( ex ) in error_messages )
)
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
2026-01-08 16:44:53 +08:00
if not should_skip_error :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , operation error: { ex } " )
2025-12-10 13:34:08 +08:00
2026-01-08 16:44:53 +08:00
except Exception as ex :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , error: { ex } " )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2026-01-08 16:44:53 +08:00
def alter_db_column_type ( migrator , table_name , column_name , new_column_type ) :
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . alter_column_type ( table_name , column_name , new_column_type ) )
except Exception as ex :
logging . critical ( f " Failed to alter { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } type, error: { ex } " )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2026-01-08 16:44:53 +08:00
def alter_db_rename_column ( migrator , table_name , old_column_name , new_column_name ) :
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . rename_column ( table_name , old_column_name , new_column_name ) )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
except Exception :
2026-01-08 16:44:53 +08:00
# rename fail will lead to a weired error.
# logging.critical(f"Failed to rename {settings.DATABASE_TYPE.upper()}.{table_name} column {old_column_name} to {new_column_name}, error: {ex}")
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2025-12-10 13:34:08 +08:00
2026-02-27 12:55:51 +01:00
def migrate_add_unique_email ( migrator ) :
""" Deduplicates user emails and add UNIQUE constraint to email column (idempotent) """
2026-03-24 20:24:24 +08:00
# step 0: check existing index state on user.email and prepare for unique constraint
2026-03-05 17:27:17 +08:00
try :
2026-03-13 11:53:01 +08:00
if settings . DATABASE_TYPE . upper ( ) == " POSTGRES " :
cursor = DB . execute_sql ( """
SELECT COUNT ( * )
FROM pg_indexes
WHERE tablename = ' user '
AND indexname = ' user_email '
""" )
2026-03-24 20:24:24 +08:00
result = cursor . fetchone ( )
if result and result [ 0 ] > 0 :
logging . info ( " UNIQUE index on user.email already exists, skipping migration " )
return
2026-03-13 11:53:01 +08:00
else :
2026-03-24 20:24:24 +08:00
# Fetch the first index on email: tells us both the name and whether it's unique.
# non_unique=0 means unique, non_unique=1 means non-unique.
2026-03-13 11:53:01 +08:00
cursor = DB . execute_sql ( """
2026-03-24 20:24:24 +08:00
SELECT index_name , non_unique
2026-03-13 11:53:01 +08:00
FROM information_schema . statistics
WHERE table_schema = DATABASE ( )
AND table_name = ' user '
2026-03-24 20:24:24 +08:00
AND column_name = ' email '
LIMIT 1
2026-03-13 11:53:01 +08:00
""" )
2026-03-24 20:24:24 +08:00
row = cursor . fetchone ( )
if row :
index_name , non_unique = row
if non_unique == 0 :
logging . info ( " UNIQUE index on user.email already exists, skipping migration " )
return
# Non-unique index exists (e.g. from old peewee index=True); drop it so
# the upcoming ADD UNIQUE INDEX does not hit MySQL error 1061 "Duplicate key name".
DB . execute_sql ( f " ALTER TABLE `user` DROP INDEX ` { index_name } ` " )
logging . info ( f " Dropped non-unique index ' { index_name } ' on user.email before adding unique index " )
2026-03-05 17:27:17 +08:00
except Exception as ex :
2026-03-24 20:24:24 +08:00
logging . warning ( f " Failed to check/prepare email index on user table: { ex } , continuing with migration " )
2026-03-05 17:27:17 +08:00
2026-02-27 12:55:51 +01:00
# step 1: rename duplicate rows so the UNIQUE constraint can be applied
try :
duplicates = User . select ( User . email ) . group_by ( User . email ) . having ( fn . COUNT ( User . id ) > 1 ) . tuples ( )
for ( dup_email , ) in duplicates :
# Keep the superuser row, or the oldest row if there is no superuser
rows = list (
User
. select ( User . id )
. where ( User . email == dup_email )
. order_by ( User . is_superuser . desc ( ) , User . create_time . asc ( ) )
. tuples ( )
)
for ( uid , ) in rows [ 1 : ] :
new_email = f " { dup_email } _DUPLICATE_ { uid [ : 8 ] } "
User . update ( email = new_email ) . where ( User . id == uid ) . execute ( )
logging . warning ( " Renamed duplicate user %s email to %s during migration " , uid , new_email )
except Exception as ex :
logging . critical ( " Failed to deduplicate user.email before adding UNIQUE constraint: %s " , ex )
return
# step 2: add UNIQUE index via migrator
try :
migrate ( migrator . add_index ( " user " , ( " email " , ) , unique = True ) )
except ( OperationalError , ProgrammingError ) as ex :
msg = str ( ex )
# MySQL 1061 "Duplicate key name" or PostgreSQL "already exists" -> already migrated
if " 1061 " in msg or " Duplicate key name " in msg or " already exists " in msg . lower ( ) :
pass
else :
logging . critical ( " Failed to add UNIQUE constraint on user.email: %s " , ex )
except Exception as ex :
logging . critical ( " Failed to add UNIQUE constraint on user.email: %s " , ex )
2026-03-05 17:27:17 +08:00
def update_tenant_llm_to_id_primary_key ( ) :
""" Add ID and set to primary key step by step. """
2026-03-13 11:53:01 +08:00
if settings . DATABASE_TYPE . upper ( ) == " POSTGRES " :
_update_tenant_llm_to_id_primary_key_postgres ( )
else :
_update_tenant_llm_to_id_primary_key_mysql ( )
def _update_tenant_llm_to_id_primary_key_mysql ( ) :
""" MySQL implementation: Add ID column and set as AUTO_INCREMENT primary key. """
2026-03-05 17:27:17 +08:00
try :
with DB . atomic ( ) :
2026-03-13 11:53:01 +08:00
# 0. Check if 'id' column already exists
2026-03-05 17:27:17 +08:00
cursor = DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA . COLUMNS
WHERE TABLE_SCHEMA = DATABASE ( )
AND TABLE_NAME = ' tenant_llm '
2026-03-05 17:27:17 +08:00
AND COLUMN_NAME = ' id '
""" )
if cursor . rowcount > 0 :
return
# 1. Add nullable column
DB . execute_sql ( " ALTER TABLE tenant_llm ADD COLUMN temp_id INT NULL " )
2026-03-13 11:53:01 +08:00
# 2. Set ID using MySQL user variables
2026-03-05 17:27:17 +08:00
DB . execute_sql ( " SET @row = 0; " )
DB . execute_sql ( " UPDATE tenant_llm SET temp_id = (@row := @row + 1) ORDER BY tenant_id, llm_factory, llm_name; " )
# 3. Drop old primary key
DB . execute_sql ( " ALTER TABLE tenant_llm DROP PRIMARY KEY " )
2026-03-13 11:53:01 +08:00
# 4. Update ID column to primary key with AUTO_INCREMENT
2026-03-05 17:27:17 +08:00
DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
ALTER TABLE tenant_llm
2026-03-05 17:27:17 +08:00
MODIFY COLUMN temp_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY
""" )
# 5. Add unique key
DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
ALTER TABLE tenant_llm
2026-03-05 17:27:17 +08:00
ADD CONSTRAINT uk_tenant_llm UNIQUE ( tenant_id , llm_factory , llm_name )
""" )
# 6. rename
DB . execute_sql ( " ALTER TABLE tenant_llm RENAME COLUMN temp_id TO id " )
logging . info ( " Successfully updated tenant_llm to id primary key. " )
except Exception as e :
logging . error ( str ( e ) )
cursor = DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA . COLUMNS
WHERE TABLE_SCHEMA = DATABASE ( )
AND TABLE_NAME = ' tenant_llm '
2026-03-05 17:27:17 +08:00
AND COLUMN_NAME = ' temp_id '
""" )
if cursor . rowcount > 0 :
2026-03-13 11:53:01 +08:00
DB . execute_sql ( " ALTER TABLE tenant_llm DROP COLUMN temp_id " )
def _update_tenant_llm_to_id_primary_key_postgres ( ) :
""" PostgreSQL implementation: Add SERIAL primary key column to tenant_llm. """
try :
with DB . atomic ( ) :
# 0. Check if 'id' column already exists
cursor = DB . execute_sql ( """
SELECT column_name
FROM information_schema . columns
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND column_name = ' id '
""" )
if cursor . rowcount > 0 :
return
# 1. Add nullable integer column
DB . execute_sql ( " ALTER TABLE tenant_llm ADD COLUMN temp_id INTEGER NULL " )
# 2. Assign sequential row numbers ordered consistently
DB . execute_sql ( """
UPDATE tenant_llm
SET temp_id = subq . rn
FROM (
SELECT ctid ,
ROW_NUMBER ( ) OVER ( ORDER BY tenant_id , llm_factory , llm_name ) AS rn
FROM tenant_llm
) AS subq
WHERE tenant_llm . ctid = subq . ctid
""" )
# 3. Drop old composite primary key constraint
cursor = DB . execute_sql ( """
SELECT constraint_name
FROM information_schema . table_constraints
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND constraint_type = ' PRIMARY KEY '
""" )
row = cursor . fetchone ( )
if row :
DB . execute_sql ( f ' ALTER TABLE tenant_llm DROP CONSTRAINT " { row [ 0 ] } " ' )
# 4. Make temp_id NOT NULL and create a sequence for it
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET NOT NULL " )
DB . execute_sql ( " CREATE SEQUENCE IF NOT EXISTS tenant_llm_id_seq " )
DB . execute_sql ( """
SELECT setval ( ' tenant_llm_id_seq ' , COALESCE ( ( SELECT MAX ( temp_id ) FROM tenant_llm ) , 0 ) )
""" )
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET DEFAULT nextval( ' tenant_llm_id_seq ' ) " )
DB . execute_sql ( " ALTER SEQUENCE tenant_llm_id_seq OWNED BY tenant_llm.temp_id " )
DB . execute_sql ( " ALTER TABLE tenant_llm ADD PRIMARY KEY (temp_id) " )
# 5. Add unique constraint
DB . execute_sql ( """
ALTER TABLE tenant_llm
ADD CONSTRAINT uk_tenant_llm UNIQUE ( tenant_id , llm_factory , llm_name )
""" )
# 6. Rename temp_id to id
DB . execute_sql ( " ALTER TABLE tenant_llm RENAME COLUMN temp_id TO id " )
logging . info ( " Successfully updated tenant_llm to id primary key (PostgreSQL). " )
except Exception as e :
logging . error ( str ( e ) )
cursor = DB . execute_sql ( """
SELECT column_name
FROM information_schema . columns
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND column_name = ' temp_id '
""" )
if cursor . rowcount > 0 :
2026-03-05 17:27:17 +08:00
DB . execute_sql ( " ALTER TABLE tenant_llm DROP COLUMN temp_id " )
2026-01-08 16:44:53 +08:00
def migrate_db ( ) :
logging . disable ( logging . ERROR )
migrator = DatabaseMigrator [ settings . DATABASE_TYPE . upper ( ) ] . value ( DB )
alter_db_add_column ( migrator , " file " , " source_type " , CharField ( max_length = 128 , null = False , default = " " , help_text = " where dose this document come from " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " rerank_id " , CharField ( max_length = 128 , null = False , default = " BAAI/bge-reranker-v2-m3 " , help_text = " default rerank model ID " ) )
alter_db_add_column ( migrator , " dialog " , " rerank_id " , CharField ( max_length = 128 , null = False , default = " " , help_text = " default rerank model ID " ) )
alter_db_column_type ( migrator , " dialog " , " top_k " , IntegerField ( default = 1024 ) )
alter_db_add_column ( migrator , " tenant_llm " , " api_key " , CharField ( max_length = 2048 , null = True , help_text = " API KEY " , index = True ) )
alter_db_add_column ( migrator , " api_token " , " source " , CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tts_id " , CharField ( max_length = 256 , null = True , help_text = " default tts model ID " , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " source " , CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True ) )
alter_db_add_column ( migrator , " task " , " retry_count " , IntegerField ( default = 0 ) )
alter_db_column_type ( migrator , " api_token " , " dialog_id " , CharField ( max_length = 32 , null = True , index = True ) )
alter_db_add_column ( migrator , " tenant_llm " , " max_tokens " , IntegerField ( default = 8192 , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " dsl " , JSONField ( null = True , default = { } ) )
alter_db_add_column ( migrator , " knowledgebase " , " pagerank " , IntegerField ( default = 0 , index = False ) )
alter_db_add_column ( migrator , " api_token " , " beta " , CharField ( max_length = 255 , null = True , index = True ) )
alter_db_add_column ( migrator , " task " , " digest " , TextField ( null = True , help_text = " task digest " , default = " " ) )
alter_db_add_column ( migrator , " task " , " chunk_ids " , LongTextField ( null = True , help_text = " chunk ids " , default = " " ) )
alter_db_add_column ( migrator , " conversation " , " user_id " , CharField ( max_length = 255 , null = True , help_text = " user_id " , index = True ) )
alter_db_add_column ( migrator , " task " , " task_type " , CharField ( max_length = 32 , null = False , default = " " ) )
alter_db_add_column ( migrator , " task " , " priority " , IntegerField ( default = 0 ) )
alter_db_add_column ( migrator , " user_canvas " , " permission " , CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True ) )
2026-03-05 17:26:39 +08:00
alter_db_add_column ( migrator , " user_canvas " , " release " , BooleanField ( null = False , help_text = " is released " , default = False , index = True ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " llm " , " is_tools " , BooleanField ( null = False , help_text = " support tools " , default = False ) )
alter_db_add_column ( migrator , " mcp_server " , " variables " , JSONField ( null = True , help_text = " MCP Server variables " , default = dict ) )
alter_db_rename_column ( migrator , " task " , " process_duation " , " process_duration " )
alter_db_rename_column ( migrator , " document " , " process_duation " , " process_duration " )
alter_db_add_column ( migrator , " document " , " suffix " , CharField ( max_length = 32 , null = False , default = " " , help_text = " The real file extension suffix " , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " errors " , TextField ( null = True , help_text = " errors " ) )
alter_db_add_column ( migrator , " dialog " , " meta_data_filter " , JSONField ( null = True , default = { } ) )
alter_db_column_type ( migrator , " canvas_template " , " title " , JSONField ( null = True , default = dict , help_text = " Canvas title " ) )
alter_db_column_type ( migrator , " canvas_template " , " description " , JSONField ( null = True , default = dict , help_text = " Canvas description " ) )
alter_db_add_column ( migrator , " user_canvas " , " canvas_category " , CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " agent_canvas|dataflow_canvas " , index = True ) )
alter_db_add_column ( migrator , " canvas_template " , " canvas_category " , CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " agent_canvas|dataflow_canvas " , index = True ) )
2026-04-13 09:26:30 -03:00
alter_db_add_column ( migrator , " canvas_template " , " canvas_types " , ListField ( null = True , default = list , help_text = " Canvas types " ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " pipeline_id " , CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True ) )
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
alter_db_add_column ( migrator , " chat_channel " , " dialog_id " , CharField ( max_length = 32 , null = True , help_text = " connected dialog id " , index = True ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " document " , " pipeline_id " , CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " graphrag_task_id " , CharField ( max_length = 32 , null = True , help_text = " Gragh RAG task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " raptor_task_id " , CharField ( max_length = 32 , null = True , help_text = " RAPTOR task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " graphrag_task_finish_at " , DateTimeField ( null = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " raptor_task_finish_at " , CharField ( null = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " mindmap_task_id " , CharField ( max_length = 32 , null = True , help_text = " Mindmap task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " mindmap_task_finish_at " , CharField ( null = True ) )
alter_db_column_type ( migrator , " tenant_llm " , " api_key " , TextField ( null = True , help_text = " API KEY " ) )
alter_db_add_column ( migrator , " tenant_llm " , " status " , CharField ( max_length = 1 , null = False , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True ) )
alter_db_add_column ( migrator , " connector2kb " , " auto_parse " , CharField ( max_length = 1 , null = False , default = " 1 " , index = False ) )
alter_db_add_column ( migrator , " llm_factories " , " rank " , IntegerField ( default = 0 , index = False ) )
2026-02-05 19:19:09 +08:00
alter_db_add_column ( migrator , " api_4_conversation " , " name " , CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = False ) )
alter_db_add_column ( migrator , " api_4_conversation " , " exp_user_id " , CharField ( max_length = 255 , null = True , help_text = " exp_user_id " , index = True ) )
2026-05-19 10:07:11 +08:00
alter_db_add_column ( migrator , " sync_logs " , " task_type " , CharField ( max_length = 32 , null = False , default = " sync " , index = True ) )
feat: Implement pluggable multi-provider sandbox architecture (#12820)
## Summary
Implement a flexible sandbox provider system supporting both
self-managed (Docker) and SaaS (Aliyun Code Interpreter) backends for
secure code execution in agent workflows.
**Key Changes:**
- ✅ Aliyun Code Interpreter provider using official
`agentrun-sdk>=0.0.16`
- ✅ Self-managed provider with gVisor (runsc) security
- ✅ Arguments parameter support for dynamic code execution
- ✅ Database-only configuration (removed fallback logic)
- ✅ Configuration scripts for quick setup
Issue #12479
## Features
### 🔌 Provider Abstraction Layer
**1. Self-Managed Provider** (`agent/sandbox/providers/self_managed.py`)
- Wraps existing executor_manager HTTP API
- gVisor (runsc) for secure container isolation
- Configurable pool size, timeout, retry logic
- Languages: Python, Node.js, JavaScript
- ⚠️ **Requires**: gVisor installation, Docker, base images
**2. Aliyun Code Interpreter**
(`agent/sandbox/providers/aliyun_codeinterpreter.py`)
- SaaS integration using official agentrun-sdk
- Serverless microVM execution with auto-authentication
- Hard timeout: 30 seconds max
- Credentials: `AGENTRUN_ACCESS_KEY_ID`, `AGENTRUN_ACCESS_KEY_SECRET`,
`AGENTRUN_ACCOUNT_ID`, `AGENTRUN_REGION`
- Automatically wraps code to call `main()` function
**3. E2B Provider** (`agent/sandbox/providers/e2b.py`)
- Placeholder for future integration
### ⚙️ Configuration System
- `conf/system_settings.json`: Default provider =
`aliyun_codeinterpreter`
- `agent/sandbox/client.py`: Enforces database-only configuration
- Admin UI: `/admin/sandbox-settings`
- Configuration validation via `validate_config()` method
- Health checks for all providers
### 🎯 Key Capabilities
**Arguments Parameter Support:**
All providers support passing arguments to `main()` function:
```python
# User code
def main(name: str, count: int) -> dict:
return {"message": f"Hello {name}!" * count}
# Executed with: arguments={"name": "World", "count": 3}
# Result: {"message": "Hello World!Hello World!Hello World!"}
```
**Self-Describing Providers:**
Each provider implements `get_config_schema()` returning form
configuration for Admin UI
**Error Handling:**
Structured `ExecutionResult` with stdout, stderr, exit_code,
execution_time
## Configuration Scripts
Two scripts for quick Aliyun sandbox setup:
**Shell Script (requires jq):**
```bash
source scripts/configure_aliyun_sandbox.sh
```
**Python Script (interactive):**
```bash
python3 scripts/configure_aliyun_sandbox.py
```
## Testing
```bash
# Unit tests
uv run pytest agent/sandbox/tests/test_providers.py -v
# Aliyun provider tests
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter.py -v
# Integration tests (requires credentials)
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py -v
# Quick SDK validation
python3 agent/sandbox/tests/verify_sdk.py
```
**Test Coverage:**
- 30 unit tests for provider abstraction
- Provider-specific tests for Aliyun
- Integration tests with real API
- Security tests for executor_manager
## Documentation
- `docs/develop/sandbox_spec.md` - Complete architecture specification
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration from legacy
sandbox
- `agent/sandbox/tests/QUICKSTART.md` - Quick start guide
- `agent/sandbox/tests/README.md` - Testing documentation
## Breaking Changes
⚠️ **Migration Required:**
1. **Directory Move**: `sandbox/` → `agent/sandbox/`
- Update imports: `from sandbox.` → `from agent.sandbox.`
2. **Mandatory Configuration**:
- SystemSettings must have `sandbox.provider_type` configured
- Removed fallback default values
- Configuration must exist in database (from
`conf/system_settings.json`)
3. **Aliyun Credentials**:
- Requires `AGENTRUN_*` environment variables (not `ALIYUN_*`)
- `AGENTRUN_ACCOUNT_ID` is now required (Aliyun primary account ID)
4. **Self-Managed Provider**:
- gVisor (runsc) must be installed for security
- Install: `go install gvisor.dev/gvisor/runsc@latest`
## Database Schema Changes
```python
# SystemSettings.value: CharField → TextField
api/db/db_models.py: Changed for unlimited config length
# SystemSettingsService.get_by_name(): Fixed query precision
api/db/services/system_settings_service.py: startswith → exact match
```
## Files Changed
### Backend (Python)
- `agent/sandbox/providers/base.py` - SandboxProvider ABC interface
- `agent/sandbox/providers/manager.py` - ProviderManager
- `agent/sandbox/providers/self_managed.py` - Self-managed provider
- `agent/sandbox/providers/aliyun_codeinterpreter.py` - Aliyun provider
- `agent/sandbox/providers/e2b.py` - E2B provider (placeholder)
- `agent/sandbox/client.py` - Unified client (enforces DB-only config)
- `agent/tools/code_exec.py` - Updated to use provider system
- `admin/server/services.py` - SandboxMgr with registry & validation
- `admin/server/routes.py` - 5 sandbox API endpoints
- `conf/system_settings.json` - Default: aliyun_codeinterpreter
- `api/db/db_models.py` - TextField for SystemSettings.value
- `api/db/services/system_settings_service.py` - Exact match query
### Frontend (TypeScript/React)
- `web/src/pages/admin/sandbox-settings.tsx` - Settings UI
- `web/src/services/admin-service.ts` - Sandbox service functions
- `web/src/services/admin.service.d.ts` - Type definitions
- `web/src/utils/api.ts` - Sandbox API endpoints
### Documentation
- `docs/develop/sandbox_spec.md` - Architecture spec
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration guide
- `agent/sandbox/tests/QUICKSTART.md` - Quick start
- `agent/sandbox/tests/README.md` - Testing guide
### Configuration Scripts
- `scripts/configure_aliyun_sandbox.sh` - Shell script (jq)
- `scripts/configure_aliyun_sandbox.py` - Python script
### Tests
- `agent/sandbox/tests/test_providers.py` - 30 unit tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter.py` - Provider tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py` -
Integration tests
- `agent/sandbox/tests/verify_sdk.py` - SDK validation
## Architecture
```
Admin UI → Admin API → SandboxMgr → ProviderManager → [SelfManaged|Aliyun|E2B]
↓
SystemSettings
```
## Usage
### 1. Configure Provider
**Via Admin UI:**
1. Navigate to `/admin/sandbox-settings`
2. Select provider (Aliyun Code Interpreter / Self-Managed)
3. Fill in configuration
4. Click "Test Connection" to verify
5. Click "Save" to apply
**Via Configuration Scripts:**
```bash
# Aliyun provider
export AGENTRUN_ACCESS_KEY_ID="xxx"
export AGENTRUN_ACCESS_KEY_SECRET="yyy"
export AGENTRUN_ACCOUNT_ID="zzz"
export AGENTRUN_REGION="cn-shanghai"
source scripts/configure_aliyun_sandbox.sh
```
### 2. Restart Service
```bash
cd docker
docker compose restart ragflow-server
```
### 3. Execute Code in Agent
```python
from agent.sandbox.client import execute_code
result = execute_code(
code='def main(name: str) -> dict: return {"message": f"Hello {name}!"}',
language="python",
timeout=30,
arguments={"name": "World"}
)
print(result.stdout) # {"message": "Hello World!"}
```
## Troubleshooting
### "Container pool is busy" (Self-Managed)
- **Cause**: Pool exhausted (default: 1 container in `.env`)
- **Fix**: Increase `SANDBOX_EXECUTOR_MANAGER_POOL_SIZE` to 5+
### "Sandbox provider type not configured"
- **Cause**: Database missing configuration
- **Fix**: Run config script or set via Admin UI
### "gVisor not found"
- **Cause**: runsc not installed
- **Fix**: `go install gvisor.dev/gvisor/runsc@latest && sudo cp
~/go/bin/runsc /usr/local/bin/`
### Aliyun authentication errors
- **Cause**: Wrong environment variable names
- **Fix**: Use `AGENTRUN_*` prefix (not `ALIYUN_*`)
## Checklist
- [x] All tests passing (30 unit tests + integration tests)
- [x] Documentation updated (spec, migration guide, quickstart)
- [x] Type definitions added (TypeScript)
- [x] Admin UI implemented
- [x] Configuration validation
- [x] Health checks implemented
- [x] Error handling with structured results
- [x] Breaking changes documented
- [x] Configuration scripts created
- [x] gVisor requirements documented
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 13:28:21 +08:00
# Migrate system_settings.value from CharField to TextField for longer sandbox configs
alter_db_column_type ( migrator , " system_settings " , " value " , TextField ( null = False , help_text = " Configuration value (JSON, string, etc.) " ) )
fix: re-chunk documents when data source content is updated (#12918)
Closes: #12889
### What problem does this PR solve?
When syncing external data sources (e.g., Jira, Confluence, Google
Drive), updated documents were not being re-chunked. The raw content was
correctly updated in blob storage, but the vector database retained
stale chunks, causing search results to return outdated information.
**Root cause:** The task digest used for chunk reuse optimization was
calculated only from parser configuration fields (`parser_id`,
`parser_config`, `kb_id`, etc.), without any content-dependent fields.
When a document's content changed but the parser configuration remained
the same, the system incorrectly reused old chunks instead of
regenerating new ones.
**Example scenario:**
1. User syncs a Jira issue: "Meeting scheduled for Monday"
2. User updates the Jira issue to: "Meeting rescheduled to Friday"
3. User triggers sync again
4. Raw content panel shows updated text ✓
5. Chunk panel still shows old text "Monday" ✗
**Solution:**
1. Include `update_time` and `size` in the chunking config, so the task
digest changes when document content is updated
2. Track updated documents separately in `upload_document()` and return
them for processing
3. Process updated documents through the re-parsing pipeline to
regenerate chunks
[1.webm](https://github.com/user-attachments/assets/d21d4dcd-e189-4d39-8700-053bae0ca5a0)
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
2026-03-06 06:48:47 +02:00
alter_db_add_column ( migrator , " document " , " content_hash " , CharField ( max_length = 32 , null = True , help_text = " xxhash128 of document content for change detection " , default = " " , index = True ) )
2026-03-05 17:27:17 +08:00
update_tenant_llm_to_id_primary_key ( )
alter_db_add_column ( migrator , " tenant " , " tenant_llm_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tenant_embd_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tenant_asr_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tenant_img2txt_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tenant_rerank_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tenant_tts_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " tenant_embd_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " dialog " , " tenant_llm_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " dialog " , " tenant_rerank_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " memory " , " tenant_embd_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
alter_db_add_column ( migrator , " memory " , " tenant_llm_id " , IntegerField ( null = True , help_text = " id in tenant_llm " , index = True ) )
2026-03-10 14:25:27 +08:00
alter_db_add_column ( migrator , " user_canvas_version " , " release " , BooleanField ( null = False , help_text = " is released " , default = False , index = True ) )
feat: add tag management for Agents with filtering and sorting (#14774) (#14799)
## Summary
Closes #14774.
Adds free-form tags on agents (UserCanvas) with full UI + API:
- Stored as comma-separated `tags` column on `UserCanvas` with online
migration.
- New endpoints: `GET /v1/agents/tags` (aggregate counts) and `PUT
/v1/agent/<id>/tags` (write). `GET /v1/agents` accepts a `tags=` query.
- "Edit tags" item in agent dropdown opens a chip-style editor dialog;
tags render as badges on each agent card.
- New "Tags" facet in the agents filter bar, with counts.
## Implementation notes
- **Tag matching is exact-token**: the SQL filter wraps stored tags as
`,…,` and matches `,ml,` so `ml` doesn't match `ml-ops`.
- **Server-side normalization** in `UserCanvasService.update_tags`:
dedup (case-insensitive), per-tag cap of 64 chars, total length capped
at 512 chars to fit the column, commas inside tag values are replaced
with spaces.
- **Tenant authorization**: `PUT /v1/agent/<id>/tags` gates on
`UserCanvasService.accessible(canvas_id, tenant_id)`.
- **Tag listing scope**: `UserCanvasService.list_tags` follows the same
own + team-shared rule as `get_by_tenant_ids`.
- **i18n**: keys added to `en.ts` and `zh.ts` only (per project
convention; other locales fall back).
- **`HomeCard`** gets a non-breaking `extra?: ReactNode` slot for the
chip row; no `src/components/ui/` files modified.
## Test plan
- [ ] Backend boot runs `migrate_db` → confirm `user_canvas.tags` column
exists (`DESCRIBE user_canvas`).
- [ ] Agents page renders cards normally (no console error from missing
field).
- [ ] `⋯ → Edit tags` opens a dialog that stays open (regression: dialog
was unmounting with the dropdown).
- [ ] Typing a tag without pressing Enter and clicking Save persists it
(regression: last typed tag was being dropped).
- [ ] Chip input supports Enter/comma to commit, Backspace on empty to
remove, `×` to remove individual chip.
- [ ] Tag containing a comma sent via API is stored with the comma
replaced by a space.
- [ ] 20 long tags sent via API does not error (length cap silently
truncates).
- [ ] "Tags" filter in the filter bar shows counts and narrows the list.
- [ ] Filtering by `ml` does **not** return agents tagged `ml-ops`.
- [ ] UI in Chinese shows 编辑标签 / 添加标签以整理和筛选你的智能体 etc.
- [ ] `PUT /v1/agent/<other-tenant-id>/tags` returns `Agent not found or
no permission.`
2026-05-13 06:41:32 -07:00
alter_db_add_column ( migrator , " user_canvas " , " tags " , CharField ( max_length = 512 , null = False , default = " " , help_text = " Comma-separated tags for organizing agents " , index = True ) )
2026-03-17 18:51:26 +08:00
alter_db_add_column ( migrator , " api_4_conversation " , " version_title " , CharField ( max_length = 255 , null = True , help_text = " canvas version title when session created " , index = False ) )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
alter_db_column_type ( migrator , " document " , " size " , BigIntegerField ( default = 0 , index = True ) )
alter_db_column_type ( migrator , " file " , " size " , BigIntegerField ( default = 0 , index = True ) )
2026-05-29 17:39:41 +08:00
alter_db_add_column ( migrator , " tenant " , " ocr_id " , CharField ( max_length = 128 , null = True , help_text = " default ocr model ID " , index = True ) )
2026-06-16 12:02:12 +08:00
alter_db_column_type ( migrator , " chat_channel " , " status " , IntegerField ( default = 1 , index = True ) )
2026-06-16 19:02:20 +08:00
alter_db_rename_column ( migrator , " chat_channel " , " dialog_id " , " chat_id " )
fix(db): drop Peewee-auto-named unique index on tenant_model_instance (#15699) (#15879)
## Summary
Fixes #15699.
User upgrades to v0.25.6 against an existing MySQL database, tries to
add an Ollama provider instance, and gets:
```
MySQL IntegrityError: Duplicate entry 'dbaafbfe608a11f1a5516d6066988224'
for key 'tenant_model_instance.tenantmodelinstance_api_key_provider_id'
```
The route at
[api/apps/restful_apis/provider_api.py:354](api/apps/restful_apis/provider_api.py#L354)
catches it and returns `get_error_data_result(message="Internal server
error")` — which by RAGFlow's convention is HTTP 200 with an error
`code` on the body — hence the reporter's "200 status code but the
database errored" complaint.
### Root cause
The provider-instance refactor in [PR
#15460](https://github.com/infiniflow/ragflow/pull/15460) dropped the
unique-compound-index tuple from `TenantModelInstance`:
```python
# Removed in #15460
class Meta:
db_table = "tenant_model_instance"
indexes = (
(("api_key", "provider_id"), True), # unique
)
```
and added a one-shot drop in `migrate_db()` for existing databases. But
the drop targets the wrong index name:
```python
# Before this PR — wrong name
for table_name, index_name in [
("tenant_model_instance", "idx_api_key_provider_id"), # ← doesn't exist
("tenant_model", "idx_provider_model_instance"),
]:
```
Peewee's auto-derived index name is `<lowercase
classname>_<col1>_<col2>` →
**`tenantmodelinstance_api_key_provider_id`**, which matches the user's
error verbatim. The drop raises `OperationalError: 1091 (HY000): Can't
DROP …`, the surrounding `except` clause at
[db_models.py:1736](api/db/db_models.py#L1736) swallows it as
expected-on-fresh-installs, and the legacy unique index lives on
indefinitely.
### Why Ollama hits it specifically
Ollama doesn't require an API key. The form posts `api_key: ""`. The
app-layer dedupe at
[provider_api_service.py:288-292](api/apps/services/provider_api_service.py#L288-L292):
```python
api_key_str = ""
if api_key: # ← skipped for ""
...
same_key_instance = TenantModelInstanceService.get_by_provider_id_and_api_key(...)
if same_key_instance:
return False, f"Already exist instance: ... with api_key {api_key}"
```
falls through for empty keys. Control reaches
`TenantModelInstanceService.create_instance(..., api_key="")` which
inserts a row whose `(api_key, provider_id) = ("", <provider_uuid>)`
collides with any prior Ollama row that already shipped that same pair →
the still-present unique index throws.
(`dbaafbfe608a11f1a5516d6066988224` in the user's error is the
duplicated `provider_id` UUID, paired with the empty `api_key`.)
### Fix
Add the Peewee auto-name alongside the existing `idx_*` entry so the
migration finally drops the obsolete index on next restart:
```python
legacy_indexes = [
("tenant_model_instance", "idx_api_key_provider_id"),
("tenant_model_instance", "tenantmodelinstance_api_key_provider_id"), # ← added
("tenant_model", "idx_provider_model_instance"),
]
```
The surrounding `try/except (OperationalError, ProgrammingError)`
matches `1091` / `can't DROP` / `does not exist` and treats them as
success, so every state is idempotent (see Test plan).
### Idempotency matrix
| Database state | First entry (`idx_api_key_provider_id`) | New entry
(`tenantmodelinstance_api_key_provider_id`) |
| --- | --- | --- |
| Fresh install (≥ #15460) — neither index exists | `1091` → swallowed |
`1091` → swallowed |
| Upgraded from before dc4b82523 (the user's case) — auto-name present |
`1091` → swallowed | **drops the index** |
| Upgraded after a manual rename to `idx_*` | drops the index | `1091` →
swallowed |
| Re-run of `migrate_db()` after either of the above | `1091` →
swallowed | `1091` → swallowed |
No rollback hazard: nothing depends on this unique constraint anymore
(`create_instance` dedupes by `instance_name` via `duplicate_name`, see
[tenant_model_instance_service.py:27](api/db/services/tenant_model_instance_service.py#L27)).
### What this PR does NOT change
- **`provider_api_service.create_provider_instance`** — its `if
api_key:` gate is correct *for the post-migration world*: multiple
Ollama instances with empty keys under one provider are legitimate, so
we shouldn't tighten the app-layer check.
- **`TenantModelInstance` Peewee model** — the `indexes` tuple was
already removed in #15460. New databases never get the constraint in the
first place.
- **The `except → get_error_data_result` → HTTP 200 pattern at
`provider_api.py:354`** — that's a project-wide convention; changing one
route to HTTP 500 would be inconsistent and out of scope.
## Test plan
- [ ] **Reproducer (pre-fix):** on a database originally created before
#15460, configure an Ollama provider with an empty `api_key`, then try
to create a *second* instance under the same provider — confirm the
`Duplicate entry … 'tenantmodelinstance_api_key_provider_id'` error in
the server log.
- [ ] **Verify the index is present pre-restart:** `SHOW INDEX FROM
tenant_model_instance WHERE Key_name =
'tenantmodelinstance_api_key_provider_id';` — non-empty result.
- [ ] **Restart with the fix applied:** server starts cleanly,
`migrate_db()` runs, no `Failed to drop index` in critical logs.
- [ ] **Verify the index is gone post-restart:** same `SHOW INDEX` query
— empty result.
- [ ] **Re-run the reproducer:** two Ollama instances under the same
provider, both `api_key=""`, both succeed.
- [ ] **Restart a second time** — no new errors; the matching `1091`
swallow keeps the migration idempotent.
- [ ] **Fresh install smoke test:** drop the DB volume, start clean — no
`1091` noise (the new index never existed), no functional regression.
## Files changed
- [api/db/db_models.py](api/db/db_models.py) — extend the legacy-index
drop list with `tenantmodelinstance_api_key_provider_id`; refactor the
inline list to a named `legacy_indexes` local with a comment pointing at
#15460 and #15699.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
Co-authored-by: Wang Qi <wangq8@outlook.com>
2026-06-11 00:47:12 -07:00
# Drop both the explicit "idx_*" name from later migrations AND the
# Peewee-auto-derived "<table-as-classname>_<col1>_<col2>" name from the
# original TenantModelInstance definition (commit dc4b82523). Databases
# created before #15460 dropped the model's `indexes = ((...,), True)`
# tuple still carry the auto-named compound unique index, which makes a
# second instance with an empty api_key (e.g. Ollama) fail with
# "Duplicate entry ... for key 'tenantmodelinstance_api_key_provider_id'"
# — see #15699.
legacy_indexes = [
( " tenant_model_instance " , " idx_api_key_provider_id " ) ,
( " tenant_model_instance " , " tenantmodelinstance_api_key_provider_id " ) ,
( " tenant_model " , " idx_provider_model_instance " ) ,
]
for table_name , index_name in legacy_indexes :
2026-06-02 13:24:53 +08:00
try :
migrate ( migrator . drop_index ( table_name , index_name ) )
except ( OperationalError , ProgrammingError ) as ex :
msg = str ( ex )
if " 1091 " in msg or " can ' t DROP " in msg . lower ( ) or " does not exist " in msg . lower ( ) or " already exists " in msg . lower ( ) :
pass
else :
logging . critical ( f " Failed to drop index { index_name } on { table_name } : { ex } " )
except Exception as ex :
logging . critical ( f " Failed to drop index { index_name } on { table_name } : { ex } " )
2025-08-15 17:44:58 +08:00
logging . disable ( logging . NOTSET )
2026-02-27 12:55:51 +01:00
# this is after re-enabling logging to allow logging changed user emails
migrate_add_unique_email ( migrator )