Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
98249ab
fix(permission): let the delete failure reach the toast that explains it
Aug 12, 2026
cda11af
refactor(permission): make the action and model pages readable at a g…
Aug 12, 2026
10f4560
fix(chat): 任务模式不再被同会话导航静默清零
jieyuhuayang Aug 12, 2026
781f5b0
feat(linsight): 任务模式支持上传文件夹并保留嵌套目录结构
jieyuhuayang Aug 12, 2026
fd2dcc4
fix(chat): 上传中删除附件不再无效,并中止该文件的传输
jieyuhuayang Aug 12, 2026
5bbeb64
fix(i18n): 补生成文件夹上传错误码的 locale 产物
jieyuhuayang Aug 12, 2026
843dc1d
fix(chat): 文件夹上传入口挂在了恒为 false 的条件上
jieyuhuayang Aug 12, 2026
53cfefd
feat(linsight): 任务模式支持上传 csv/py 等数据与代码文件
jieyuhuayang Aug 12, 2026
4659b09
fix(linsight): csv/txt/md 也不再走解析,_PARSE_WINS_EXTS 收窄到 html
jieyuhuayang Aug 12, 2026
5bcaa93
fix(linsight): 任务模式提交改为服务端入队,不再依赖浏览器回调
jieyuhuayang Aug 13, 2026
ecaba01
test(linsight): 修 start-execute 既有测试被服务端入队改动打挂
jieyuhuayang Aug 13, 2026
6d7fc76
feat(linsight): 任务模式轮次预算 115→600,子代理 30→120
jieyuhuayang Aug 13, 2026
2100498
feat(config): 启动时把新增配置项增量补进 initdb_config,不覆盖已有值
jieyuhuayang Aug 13, 2026
8ade06a
feat(workflow): cap media uploads at 5 on the platform run page
Aug 13, 2026
60bf7c3
feat(citation): play audio/video attachments in the source panel
Aug 13, 2026
b4ab8b9
fix(citation): play media in the source panel and download the origin…
Aug 13, 2026
6127103
style(citation): stack the media preview, player over transcript
Aug 13, 2026
fab29b1
fix(linsight): glob 认不出提示词自己教的 /uploads/**/*.xlsx 写法
jieyuhuayang Aug 13, 2026
f4cf5e4
fix(report): keep the template's formatting when filling placeholders
Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/backend/bisheng/common/errcode/linsight.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ class FileUploadError(BaseErrorCode):
Msg: str = "Upload Failed"


# ---------------------------------------------------------------------------
# Task-mode FOLDER upload limits (11021–11023). Numbered next to FileUploadError
# (11020) because they belong to the same upload family. The frontend enforces
# the same three numbers for instant feedback; these are the server-side gate,
# so a user normally never sees them.
# ---------------------------------------------------------------------------


class LinsightFolderFileCountExceededError(BaseErrorCode):
Code: int = 11021
Msg: str = "A folder upload may contain at most 100 files"


class LinsightFolderTotalSizeExceededError(BaseErrorCode):
Code: int = 11022
Msg: str = "A folder upload may not exceed 500MB in total"


class LinsightFolderDepthExceededError(BaseErrorCode):
Code: int = 11023
Msg: str = "Folder nesting may not exceed 10 levels"


# Per-file ceiling on the task-mode upload endpoint. It had none until folder
# upload made the gap material — 100 files arrive in one go, and a frontend-only
# check is a hint, not a limit.
class LinsightFileTooLargeError(BaseErrorCode):
Code: int = 11024
Msg: str = "The file exceeds the upload size limit"


# Your Idea has run out of uses, please use the new invite code to activate the Idea feature
class LinsightUseUpError(BaseErrorCode):
Code: int = 11030
Expand Down
162 changes: 162 additions & 0 deletions src/backend/bisheng/common/services/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,168 @@ async def init_config(self):
except Exception as e:
logger.exception(e)
await session.rollback()
return

# The row exists, so the branch above will never run again on this
# install — which used to mean a setting shipped in a later release
# never reached the database at all. Backfill the missing keys (values
# already stored are left exactly as they are, so an operator's tuning
# survives every upgrade).
await self._backfill_missing_config(session, config, config_content, all_config_key)

async def _backfill_missing_config(
self, session, config: list[Config], config_content: str, all_config_key: str
) -> None:
"""Add newly shipped config keys to the stored config; never overwrite.

Best-effort by design: a failure here must not stop the service from
booting, because the code paths that read these settings all have their
own defaults.
"""
try:
db_row = next((conf for conf in config if conf.key == all_config_key), None)
if db_row is None or not db_row.value:
return
merged, added = self.merge_missing_config(config_content, db_row.value)
if not added:
return
# Re-parse before writing: a malformed merge must never replace a
# working configuration.
yaml.safe_load(merged)
db_row.value = merged
session.add(db_row)
await session.commit()
# The read path caches this row in redis for 100s; drop it so the new
# keys are visible immediately instead of after the TTL.
try:
get_redis_client_sync().delete("config:initdb_config")
except Exception:
logger.warning("config backfill: redis cache eviction failed; new keys apply within 100s")
logger.info(f"config backfill: added {len(added)} missing key(s) to initdb_config: {added}")
except Exception:
logger.exception("config backfill failed; stored configuration left untouched")
try:
await session.rollback()
except Exception:
logger.warning("config backfill: rollback failed")

@staticmethod
def _extract_block(lines: list[str], key: str, indent: str) -> list[str]:
"""Lines of ``<indent>key:`` and everything nested under it, plus the
comment lines directly above it.

Text-level on purpose: a yaml.safe_load/dump round-trip would drop every
comment in the file, and those comments are what operators read in the
system-config page.
"""
prefix = f"{indent}{key}:"
for i, line in enumerate(lines):
if not (line == prefix.rstrip() or line.startswith(f"{prefix} ") or line.startswith(prefix)):
continue
# Walk up over the comment block that documents this key.
start = i
while start > 0 and lines[start - 1].strip().startswith("#"):
start -= 1
# Walk down while lines are blank or nested deeper than this key.
end = i + 1
while end < len(lines):
nxt = lines[end]
if not nxt.strip():
end += 1
continue
if len(nxt) - len(nxt.lstrip()) > len(indent):
end += 1
continue
break
# Trailing blank lines belong to the separation, not to the block.
while end > i + 1 and not lines[end - 1].strip():
end -= 1
return lines[start:end]
return []

@staticmethod
def merge_missing_config(file_config: str, db_config: str) -> tuple[str, list[str]]:
"""Add keys that exist in the shipped yaml but not in the stored config.

Returns ``(merged_text, added_paths)``; ``added_paths`` is empty when the
stored config already covers the file.

Why this exists: ``init_config`` only ever WRITES the yaml when the DB has
no ``initdb_config`` row at all, so on any environment that has been
installed once, a newly shipped setting never reaches the database. Values
then silently fall back to the Field defaults in ``settings.py`` — and only
for as long as nobody writes that section from the config page. A release
that adds ``linsight.max_model_turns`` is invisible to every existing
install; that is exactly how a 115-turn budget stayed in force after the
default had moved on.

Rules, in order of importance:
1. NEVER overwrite a value the DB already has — an operator's tuning
always wins, which is what makes running this on every boot safe.
2. Keys the DB has and the file does not are kept untouched.
3. New keys are carried over as RAW TEXT together with their comments.
4. Two levels deep: a missing top-level section comes over whole
(nested content included); inside a section that already exists, only
its missing direct children are inserted.
"""
file_cfg = yaml.safe_load(file_config) or {}
db_cfg = yaml.safe_load(db_config) or {}
if not isinstance(file_cfg, dict) or not isinstance(db_cfg, dict):
return db_config, []

added: list[str] = []
file_lines = file_config.split("\n")
out_lines = db_config.split("\n")

for key, file_value in file_cfg.items():
if key not in db_cfg:
block = ConfigService._extract_block(file_lines, key, "")
if block:
while out_lines and not out_lines[-1].strip():
out_lines.pop()
out_lines.extend(["", "", *block])
added.append(key)
continue

# Section present on both sides — fill in only its missing children.
db_value = db_cfg.get(key)
if not isinstance(file_value, dict) or not isinstance(db_value, dict):
continue
missing = [sub for sub in file_value if sub not in db_value]
if not missing:
continue

section = ConfigService._extract_block(file_lines, key, "")
if not section:
continue
# Re-locate the section on every key: earlier insertions shift the lines.
anchor = None
for i, line in enumerate(out_lines):
if line.startswith(f"{key}:"):
anchor = i
break
if anchor is None:
continue
end = anchor + 1
while end < len(out_lines):
nxt = out_lines[end]
if not nxt.strip() or nxt.startswith((" ", "\t")):
end += 1
continue
break
while end > anchor + 1 and not out_lines[end - 1].strip():
end -= 1

insert: list[str] = []
for sub in missing:
block = ConfigService._extract_block(section, sub, " ")
if block:
insert.extend(block)
added.append(f"{key}.{sub}")
if insert:
out_lines[end:end] = insert

return "\n".join(out_lines), added

@staticmethod
def merge_old_config(new_config: str, old_db_config: list[Config], old_db_keys: dict[str, str]):
Expand Down
6 changes: 3 additions & 3 deletions src/backend/bisheng/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,21 +377,21 @@ class LinsightConf(BaseModel):
default=100000, description="Maximum Tool Execution Historytoken, you need to summarize your history after"
)
max_steps: int = Field(
default=500,
default=2500,
description="LangGraph ``recursion_limit`` for a task graph — a runaway FUSE, not the business "
"budget. It counts SUPER-STEPS, not model turns: one model turn costs ~4 super-steps "
"(model -> tool-loop-breaker.after_model -> TodoList.after_model -> tools), so 500 ~= 115 turns. "
"To bound how long a task may work, tune ``max_model_turns`` instead; task_exec raises this "
"value automatically if it would trip before the turn budget.",
)
max_model_turns: int = Field(
default=115,
default=600,
description="Turn budget for the MAIN graph: how many model calls one task run may make before "
"the soft-landing ladder forces it to wrap up. This is the real business gate (see max_steps). "
"Reset on every ask_user resume, since the middleware instance is rebuilt with the agent.",
)
max_model_turns_subagent: int = Field(
default=30,
default=120,
description="Turn budget for the researcher subagent's own graph (counted separately — a subgraph "
"runs its own Pregel loop with its own step counter).",
)
Expand Down
14 changes: 10 additions & 4 deletions src/backend/bisheng/initdb_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,22 @@ linsight:
# (model → 工具循环熔断器 after_model → TodoList after_model → tools)。
# 另外「只产出 write_todos」的空转轮会退还预算(每个预算桶最多退 10 轮),
# 所以实际模型调用可能超过 max_model_turns,保险丝要留出这部分余量:
# (115 + 10) × 4 + 20 = 520,取 600 留头。
# (600 + 10) × 4 + 20 = 2460,取 2500 留头。
# 它是跑飞时的保险丝,真正的业务上限请调 max_model_turns。
# 若本值低于所需,后端会自动抬高并打 warning 日志。
max_steps: 600
max_steps: 2500
# 主图轮次预算:一次任务运行最多允许多少次模型调用,到顶前会提示模型收尾(软着陆)。
# ask_user 打断恢复后重新计数(中间件随 agent 重建)。
max_model_turns: 115
# 115 对真实的投标类任务远远不够:一次「9 份 PDF / 833 页 / 109 项参数逐项提取」的
# 任务在 115 轮处只走完计划的前 3 步(页级索引与项目结构核实),一项参数都没提取就
# 被迫收尾;而同类任务的工作量还会再翻一倍,故按最重的场景给到 600。
# ⚠️ 代价是跑飞时烧得更久(最坏约 2460 个 super-step):超大任务仍建议拆成多次,
# 单次任务还受 checkpoint 写放大与上下文长度制约,堆高预算先撞到的往往是这两个。
max_model_turns: 600
# 子代理(researcher)轮次预算。⚠️ 按「每次 task 调用」独立计数,不是整次任务共享:
# 主图并行委派 2 个子代理时,每个各得完整一份,互不抢额度。
max_model_turns_subagent: 30
# 120:逐项提取/交叉校验这类重活由子代理承担,30 轮往往只够读完材料;与主图同比例放宽。
max_model_turns_subagent: 120
# 距离预算耗尽还剩多少轮时开始提示模型收尾;最后 2 轮只保留写文件/导出工具,
# 归零时不再提供任何工具,模型只能输出文本,图正常结束
soft_landing_turns: 8
Expand Down
35 changes: 22 additions & 13 deletions src/backend/bisheng/linsight/api/endpoints/linsight.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from bisheng.api.v1.schemas import UnifiedResponseModel, resp_200
from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum, BaseTelemetryTypeEnum
from bisheng.common.dependencies.user_deps import UserPayload
from bisheng.common.errcode import BaseErrorCode
from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError
from bisheng.common.errcode.linsight import (
FileUploadError,
Expand Down Expand Up @@ -85,6 +86,10 @@ async def upload_file(
"file_name": upload_result.get("original_filename"),
"parsing_status": upload_result.get("parsing_status"),
}
except BaseErrorCode as e:
# Typed business errors (e.g. the per-file size ceiling) keep their own
# code so the client can tell "too large" apart from a generic failure.
return e.return_resp_instance()
except Exception as e:
logger.error(f"Upload Failed: {e!s}")
return FileUploadError.return_resp()
Expand Down Expand Up @@ -199,6 +204,11 @@ async def event_generator():
"message_session": message_session_model.model_dump(),
"linsight_session_version": linsight_session_version_model.model_dump(),
}
except BaseErrorCode as e:
# Typed business errors (folder-upload limits, …) keep their own code so
# the client can show the right copy instead of a generic submit failure.
yield e.to_sse_event_instance()
return
except Exception as e:
yield LinsightQuestionError(exception=e).to_sse_event_instance()
return
Expand Down Expand Up @@ -243,25 +253,24 @@ async def start_execute(
if session_version_model.status in [
SessionVersionStatusEnum.COMPLETED,
SessionVersionStatusEnum.TERMINATED,
SessionVersionStatusEnum.IN_PROGRESS,
]:
# The Inspiration session version has been completed or is being executed and cannot be executed again
# A finished session must not be re-run.
return LinsightSessionVersionRunningError.return_resp()

await MessageSessionDao.touch_session(session_version_model.session_id)
if session_version_model.status == SessionVersionStatusEnum.IN_PROGRESS:
# Already running — report success rather than an error. Submit now
# enqueues server-side, so by the time the client's start-execute lands
# the worker has often already picked the session up. Answering with an
# error there made the frontend's `.catch` mark a perfectly healthy task
# as failed (taskError + Stoped). "Is it running?" is what the caller
# actually wants to know, and the answer is yes.
logger.info(f"start-execute: session {linsight_session_version_id} already running; treating as no-op")
return resp_200(data=True, message="Ideas execution task is already running")

from bisheng.linsight.worker import LinsightQueue, encode_queue_item
await MessageSessionDao.touch_session(session_version_model.session_id)

try:
redis_client = await get_redis_client()
queue = LinsightQueue("queue", namespace="linsight", redis=redis_client)

await queue.put(
data=encode_queue_item(
linsight_session_version_id,
tenant_id=session_version_model.tenant_id,
)
)
await linsight_execute_utils.enqueue_session_for_execution(session_version_model)

except Exception as e:
logger.error(f"Failed to start the Ideas task: {e!s}")
Expand Down
10 changes: 10 additions & 0 deletions src/backend/bisheng/linsight/domain/schemas/linsight_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class SubmitFileSchema(BaseModel):
# linsight parses it on-the-fly via TempFilePipeline at ingestion instead of
# resolving a linsight Redis temp_info / pre-parsed markdown.
file_url: str | None = Field(None, description="Daily-bucket raw file path (workstation upload)")
# Folder upload: this file's path relative to the folder the user dropped,
# e.g. ``年报/2024/Q1.xlsx``. None/empty means a plain single-file upload and
# keeps the historical flat ``uploads/<name>`` layout. The workspace rebuilds
# the directory tree from this value, so it is sanitized segment-wise
# (``_safe_relpath``) before it ever reaches an object key.
relative_path: str | None = Field(None, description="Path relative to the uploaded folder root")
# Client-reported size, used ONLY for the batch-level folder-upload gate
# (all-or-nothing UX, mirroring knowledge-space ``FolderUploadItem.size``).
# The authoritative per-file size check runs at the upload endpoint.
size: int = Field(0, ge=0, description="File size in bytes (client-reported, batch gate only)")


# Submit a problemSchema
Expand Down
Loading
Loading