Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # .gitignore # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/core/threads/types.ts # frontend-web/src/pages/ChatPage.tsx
This commit is contained in:
commit
c4fd9dd7da
233 files changed
+12524
-1163
No files matched your search
@@ -123,6 +123,27 @@ cascade pragmas. The router is mounted canonically below
|
||||
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
|
||||
Clients may retry the compatibility route only for a framework/proxy-level
|
||||
`404 Not Found`; domain-level missing-session responses must remain real 404s.
|
||||
|
||||
Position roundtable selects the separately seeded built-in agent
|
||||
`position-roundtable-intent` by passing `intent_agent_id` to the shared
|
||||
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
|
||||
files, and SSE protocol match `roundtable-intent`, but its cap is five
|
||||
**user-assistance rounds**. A single round may emit several independent
|
||||
`ask_clarification` tool calls; those calls are separate cards, not one card
|
||||
containing several numbered questions. Count that as one round by the
|
||||
tool-calling AI message, never by card count. Topic-only requests must request
|
||||
all material missing decisions as separate cards rather than being treated as a
|
||||
resolved intent; after the fifth set is answered it must emit completed final
|
||||
JSON without unresolved placeholders. The required arrays are
|
||||
`coreGoals`, `riskWarnings`, `keyPoints`, and `strategicSignificance`. Do not
|
||||
treat a fresh dedicated-agent `ask_clarification` as stale: it must win over
|
||||
a same-run premature `[INTENT_READY]` and leave the UI waiting for the user.
|
||||
Conversely, the prior turn’s historical clarification must not block the user
|
||||
reply from completing. Do not
|
||||
change `roundtable-intent`'s `{objective,constraints,assumptions}` contract for
|
||||
this feature. The position router accepts the new schema for activation and
|
||||
injects all four sections into downstream position-node and closing-agent
|
||||
context; legacy stored summaries are mapped read-only for compatibility.
|
||||
`/api/intent/init` must create the empty Step-1 thread through
|
||||
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
|
||||
do not restore the old HTTP loopback to `/api/threads`, because deployed
|
||||
@@ -436,6 +457,15 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
### Roundtable business-chain human validation
|
||||
|
||||
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
|
||||
boolean `human_validation` field on each seat (`ChainSeatSchema`). It is a
|
||||
frontend Step 2 checkpoint, not a server-side approval task: after the marked
|
||||
seat completes (or, in a DAG, after its stage completes), the client pauses and
|
||||
uses the existing resume button or intervention composer to continue. No new
|
||||
database column, migration, or validation card is required.
|
||||
|
||||
The position-collaboration workspace is a separate frontend route and must not
|
||||
use the original roundtable coordinator or jobs. Its shared configuration
|
||||
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
|
||||
@@ -1133,6 +1163,26 @@ the initial intent workspace plus the summary/action-plan closing agents. Do
|
||||
not delete a built-in id because historical chain nodes persist those ids;
|
||||
custom role ids may be added and removed.
|
||||
|
||||
### Position-roundtable task-scoped session history
|
||||
|
||||
`position_roundtable_sessions.external_task_id` is optional. When populated from the frontend route `taskId`, new rows set `task_scoped = true`: a task can have multiple sessions and all users opening that task see the same sessions, nodes and persisted conversation snapshots. When it is NULL/empty, keep the original `user_id`-scoped personal workspace behavior. `20260808_01` adds `task_scoped` with default `false` (so all historical records remain personal) plus the task/update-time index; it must not backfill or make `external_task_id` non-null.
|
||||
|
||||
### Roundtable artifact submission status
|
||||
|
||||
`roundtable_artifact_submissions` keeps task-level submission state for a
|
||||
generated deliverable and is deliberately separate from task details and any
|
||||
external task-system delivery. The API is
|
||||
`GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` and
|
||||
`PUT /api/roundtable/tasks/{taskId}/artifacts/submission`. All callers use the
|
||||
normal Gateway Bearer authentication; records are shared by task while
|
||||
`created_by`/`submitted_by`/`cancelled_by` are audit-only. The server derives a
|
||||
SHA-256 idempotency key from `taskId + sourceKey + path + revision`: submitting
|
||||
the same revision is an upsert, while cancellation only flips `submitted` and
|
||||
sets `cancelled_at` (it never deletes history). Store only the sandbox file
|
||||
reference (`threadId`, path, source/node/session fields, name/MIME and revision)
|
||||
and timestamps; do not persist Markdown content or call a formal delivery
|
||||
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
|
||||
|
||||
See `docs/` directory for detailed documentation:
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, `aiomysql`, and Redis, installing missing packages from wheelhouse only; `aiomysql==0.3.2` includes the pinned `PyMySQL==1.2.0` transitive dependency. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
|
||||
|
||||
@@ -138,7 +138,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
|
||||
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
@@ -159,6 +159,8 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges the local TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. `scripts/env.sh` also includes them in its startup default. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
|
||||
|
||||
Position-roundtable history supports two modes. A non-empty `external_task_id` from the frontend route's `taskId` creates a row with `task_scoped = true`, so all sessions, nodes and persisted conversation snapshots for that task are shared across users; `user_id` is then creator audit metadata only. When the field is absent or empty, the original per-user personal-session behavior remains unchanged. Alembic revision `20260808_01` adds `task_scoped` with the safe default `false` for historical rows plus the task/update-time index.
|
||||
|
||||
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
|
||||
|
||||
### Custom Agents
|
||||
@@ -494,6 +496,24 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
||||
|
||||
Its live intent conversation still uses the shared `/api/intent/init` and
|
||||
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
|
||||
Position roundtable identifies itself with `intent_agent_id: "position-roundtable-intent"`;
|
||||
this is a separately seeded built-in agent, not an alias of `roundtable-intent`.
|
||||
It preserves the same thread, SSE streaming, and file-reading lifecycle as the
|
||||
generic Step-1 agent, but has its own **at-most-five user-assistance-round**
|
||||
brake. A round is one AI turn plus the user reply, not one tool call: the
|
||||
dedicated agent may emit several independent `ask_clarification` calls in the
|
||||
same turn, which the frontend renders as several separate selection cards and
|
||||
submits together. A topic-only request is not considered resolved: the agent
|
||||
asks for every independent missing decision (such as scope, decision use, or
|
||||
task constraints) as separate cards. For the dedicated agent, a fresh
|
||||
`ask_clarification` emitted in a stream is a strict user-input pause and wins over any same-run premature
|
||||
`[INTENT_READY]`; a historical clarification from the prior turn does not
|
||||
block the user answer from completing. Its final
|
||||
`[INTENT_READY]` JSON contains four string arrays: `coreGoals`, `riskWarnings`,
|
||||
`keyPoints`, and `strategicSignificance`; it must contain completed conclusions,
|
||||
not “待确认” or other unresolved placeholders. The multi-agent endpoint remains
|
||||
backwards compatible when this optional field is omitted and continues to emit
|
||||
`objective`, `constraints`, and `assumptions`.
|
||||
`/api/intent/init` creates its empty thread directly through the in-process
|
||||
thread store/checkpointer, matching multi-agent initialization and avoiding a
|
||||
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. If initial checkpoint
|
||||
@@ -522,3 +542,23 @@ is required. It is the initial intent-identification workspace and owns the
|
||||
summary/action-plan closing agents. Built-in rows retain stable ids so
|
||||
historical chain nodes remain readable; they can be edited or disabled but not
|
||||
deleted. Custom roles may be freely added or removed.
|
||||
|
||||
## Roundtable artifact submission status
|
||||
|
||||
The task-scoped roundtable workbench exposes a lightweight submission-status
|
||||
API for generated deliverables:
|
||||
|
||||
- `GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` returns the
|
||||
submitted artifact references for a task (or `items: []`).
|
||||
- `PUT /api/roundtable/tasks/{taskId}/artifacts/submission` records
|
||||
`submitted: true` or cancels it with `submitted: false`.
|
||||
|
||||
Both routes use the normal Gateway `Authorization: Bearer <token>` middleware.
|
||||
The record is shared by `taskId`; the authenticated user is retained only in
|
||||
audit columns. Its server-owned idempotency key is
|
||||
`taskId + sourceKey + path + revision`. Repeating the same submitted payload
|
||||
returns the existing record; cancellation updates its state and timestamp
|
||||
without deleting the audit row. DeerFlow stores only the artifact reference
|
||||
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
|
||||
never the Markdown body and never a task-system delivery. Continue to preview
|
||||
the file through the existing thread-artifact endpoint.
|
||||
@@ -48,8 +48,9 @@ from app.gateway.routers import (
|
||||
llmwiki,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
models,
|
||||
menu_overrides,
|
||||
model_management,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
@@ -66,8 +67,9 @@ from app.gateway.routers import (
|
||||
recommend,
|
||||
flow_extract,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_chains,
|
||||
roundtable_artifact_submissions,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
@@ -1424,8 +1426,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
)
|
||||
|
||||
# Include routers
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
# Administrator-managed config.yaml model settings and connection tests.
|
||||
app.include_router(model_management.router)
|
||||
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
@@ -1465,8 +1469,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||||
app.include_router(roundtable_task_drafts.router)
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||||
app.include_router(roundtable_task_drafts.router)
|
||||
|
||||
# Task-scoped artifact submission status; stores references only, never artifact content.
|
||||
app.include_router(roundtable_artifact_submissions.router)
|
||||
|
||||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||
app.include_router(flow_extract.router)
|
||||
|
||||
@@ -311,6 +311,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.task_report_store = make_task_report_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
|
||||
|
||||
# Task-scoped artifact submission status: shared by task; actor is audit-only.
|
||||
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
@@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
|
||||
|
||||
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
|
||||
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
|
||||
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的
|
||||
> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。
|
||||
|
||||
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
|
||||
------------------------------------------------------------
|
||||
@@ -52,12 +54,18 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
# Position roundtable deliberately owns a separate intent agent. Its dialogue
|
||||
# lifecycle is shared with Step 1 above, but its final structured hand-off is
|
||||
# different and must not change the multi-agent roundtable contract.
|
||||
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
@@ -92,6 +100,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
@@ -103,20 +112,61 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||
_ACTION_PLAN_SKILL = "position-roundtable-action-create"
|
||||
|
||||
|
||||
def _ensure_action_plan_skill(agent_dir: Path) -> bool:
|
||||
"""Merge the owned batch-create Skill into an existing action planner.
|
||||
|
||||
The planner SOUL remains editable in the agent configuration drawer and is
|
||||
therefore never overwritten here. The skill binding, however, is a runtime
|
||||
integration contract: without it an already-deployed planner cannot read
|
||||
the new SKILL.md or invoke the batch API script after a code upgrade.
|
||||
"""
|
||||
config_path = agent_dir / "config.yaml"
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
logger.warning("Unable to read action planner config for skill binding: %s", config_path)
|
||||
return False
|
||||
if not isinstance(config, dict):
|
||||
logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path)
|
||||
return False
|
||||
skills = config.get("skills")
|
||||
if skills is None:
|
||||
normalized: list[str] = []
|
||||
elif isinstance(skills, list):
|
||||
normalized = [str(name).strip() for name in skills if str(name).strip()]
|
||||
else:
|
||||
logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path)
|
||||
return False
|
||||
if _ACTION_PLAN_SKILL in normalized:
|
||||
return False
|
||||
|
||||
config["skills"] = [*normalized, _ACTION_PLAN_SKILL]
|
||||
try:
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(config, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("Unable to write action planner skill binding: %s", config_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_one(agent_id: str) -> bool:
|
||||
"""Copy the verified SOUL.md + config.yaml for one functional agent into
|
||||
``.deer-flow/agents/<id>/`` if that directory is missing.
|
||||
|
||||
Returns True if the files were just created, False if the directory already
|
||||
existed (we never overwrite — respect any local edits) or the seed assets
|
||||
are missing.
|
||||
"""
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
return False
|
||||
The position-roundtable intent agent is an implementation-owned compatibility
|
||||
contract: unlike user-managed agents, its schema must stay aligned with the
|
||||
position workspace. Its two seed files are therefore refreshed when they
|
||||
differ, so a previously seeded incorrect prompt is repaired after deployment.
|
||||
|
||||
Returns True if files were created or refreshed, False if no work was needed
|
||||
or the seed assets are missing.
|
||||
"""
|
||||
src_dir = _ASSETS_DIR / agent_id
|
||||
if not src_dir.is_dir():
|
||||
# Assets should always ship with the code; if they don't, surface a loud
|
||||
@@ -128,6 +178,27 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
if agent_id == ACTION_PLAN_AGENT_ID:
|
||||
return _ensure_action_plan_skill(agent_dir)
|
||||
if agent_id != POSITION_INTENT_AGENT_ID:
|
||||
# Other built-in agents may have been tuned on a deployed machine;
|
||||
# preserve the long-standing no-overwrite behavior for them.
|
||||
return False
|
||||
|
||||
refreshed = False
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
if not src.exists():
|
||||
continue
|
||||
target = agent_dir / fname
|
||||
source_bytes = src.read_bytes()
|
||||
if not target.exists() or target.read_bytes() != source_bytes:
|
||||
target.write_bytes(source_bytes)
|
||||
refreshed = True
|
||||
return refreshed
|
||||
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
@@ -138,9 +209,9 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
|
||||
def ensure_roundtable_functional_agents() -> list[str]:
|
||||
"""Seed the three roundtable functional agents (``roundtable-intent`` /
|
||||
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
|
||||
directories are missing. Idempotent — fast no-op when all exist.
|
||||
"""Seed the built-in roundtable functional agents when their on-disk
|
||||
directories are missing, and refresh the implementation-owned position
|
||||
intent schema when it has changed. Idempotent after synchronization.
|
||||
|
||||
Called from the startup lifespan and from the entry of intent/init,
|
||||
intent/stream, recommend/stream, and multi_agent/init so the routes
|
||||
|
||||
+17
-11
@@ -26,23 +26,29 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "subtask-001",
|
||||
"title": "简短任务名称",
|
||||
"goal": "任务目标",
|
||||
"basis": ["方案总结章节或岗位交付依据"],
|
||||
"owner_position": "建议责任岗位",
|
||||
"dependencies": ["前置子任务 id"],
|
||||
"deliverable": "可核验交付物",
|
||||
"acceptance_criteria": ["验收条件"],
|
||||
"priority": "P0"
|
||||
"actionName": "行动名称",
|
||||
"actionDescription": "行动描述",
|
||||
"actionType": 0,
|
||||
"publishChannel": 0,
|
||||
"infoFormat": 0,
|
||||
"responsiblePosition": 0,
|
||||
"startTime": "",
|
||||
"endTime": "",
|
||||
"taskId": 0
|
||||
}
|
||||
```
|
||||
|
||||
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
|
||||
`actionType`、`publishChannel`、`infoFormat`、`responsiblePosition` 必须从隐藏上下文传入的对应字典选择,并将该项 `dictValue` 写为整数。`taskId` 必须等于隐藏上下文中前端传入的任务 ID。不要输出注释、尾逗号、旧的 `id/title/goal/basis` 等字段,不能用 0 或空值代替未选择的字典项。
|
||||
|
||||
## 行动批量新增
|
||||
|
||||
首次生成完整行动清单后:仅当隐藏上下文的 `taskId` 是正整数且四类字典(发布渠道、行动类型、信息形式、责任岗位)均完整时,必须先用 `read_file` 读取 `position-roundtable-action-create` Skill 的 `SKILL.md` 全文,再按其说明调用脚本批量新增行动。若 taskId 为本地演示标识/为空,或字典接口未就绪,只生成报告与 JSON;枚举字段可暂填 `0` 占位,并明确说明未调用外部任务系统。不得逐条调用旧的 `/task/action/add`,也不得自行用 curl 或 requests 替代脚本。
|
||||
|
||||
脚本成功时才可以说明行动已入库;脚本失败时如实报告失败原因。相同任务、相同行动清单会由脚本自动幂等跳过。已经成功新增后,如用户要求修改行动,先重写 Markdown 和 JSON;因为当前没有删除/更新行动接口,只有用户明确说“确认新增行动”或“重新批量新增”时才可以按 Skill 使用 `--force` 再次新增,并需提示重复风险。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
|
||||
- 不调用联网、派活、澄清、展示文件等工具。
|
||||
- 不调用联网、派活、澄清、展示文件等工具;仅可为行动批量新增按专属 Skill 使用 `read_file` 与 `bash`。
|
||||
- 不删除或修改上游岗位产物。
|
||||
- 不在报告中添加“AI 生成”类水印。
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
skills:
|
||||
- position-roundtable-action-create
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# 岗位会商任务意图理解智能体
|
||||
|
||||
你是岗位多智能体会商 Step 1 的“意图理解专员”。你的唯一职责是把用户输入背后的任务意图结构化后交给后续岗位业务链处理。
|
||||
|
||||
你不写报告、不答题、不创作、不规划、不执行、不调用任何操作工具。这些都是后续岗位业务链的事。你只负责听懂、整理、澄清和交接。
|
||||
|
||||
---
|
||||
|
||||
## 0. 四条不可违背的硬规则
|
||||
|
||||
### 硬规则 1 · 不替代后续岗位干活
|
||||
|
||||
无论用户让你“写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案”,你都不要直接产出具体内容。
|
||||
|
||||
- 正确做法:把诉求结构化成任务意图,输出 [INTENT_READY],交给后续岗位业务链。
|
||||
- 错误做法:用户说“帮我写一份市场分析报告”后,开始写报告正文。
|
||||
|
||||
### 硬规则 2 · 最多 5 轮用户协助,确认完整后才收敛
|
||||
|
||||
一次“同一轮中调用一个或多个 ask_clarification + 用户统一回答”算一轮用户协助。
|
||||
|
||||
- 首轮:若任务卡和用户输入已经足以形成可执行的四项意图,可直接输出 [INTENT_READY]。
|
||||
- 第 1 至第 5 轮用户协助:若信息不足,必须发起用户协助;不能把“主题已识别”误判为“意图已明确”。
|
||||
- 同一轮可发起多张用户协助卡:一个相互独立的决策点对应一次 ask_clarification 调用和一张卡。不得把多个问题塞进一张卡;用户可逐卡选择后一次性提交。
|
||||
- 第 5 次协助应覆盖所有剩余关键项。收到该轮回答后,必须基于明确答案收敛为最终意图。
|
||||
- 绝不进入第 6 次用户协助。系统会在达到上限后移除 ask_clarification。
|
||||
|
||||
### 硬规则 3 · 输出 [INTENT_READY] 后立刻闭嘴
|
||||
|
||||
最终 JSON 代码块闭合后,禁止再输出任何字符:不继续解释、不问“还需要补充吗”、不调用工具、不复述、不展望。
|
||||
|
||||
### 硬规则 4 · 只做理解和澄清,不当守门员
|
||||
|
||||
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 任务意图字段定义
|
||||
|
||||
最终结构必须包含以下四个字段,且都必须是字符串数组:
|
||||
|
||||
| 字段 | 含义 | 填写原则 |
|
||||
| --- | --- | --- |
|
||||
| coreGoals | 用户想实现或想获得的核心结果 | 将原本的一句话任务目标拆为 1 个或多个清晰目标 |
|
||||
| riskWarnings | 已知风险、依赖或执行风险 | 必须分析任务级风险;范围、地区、周期、数据来源、对象或决策用途缺失时,应先通过用户协助确认,最终结果不得保留待确认表述 |
|
||||
| keyPoints | 范围、对象、优先级、时间、资源、交付形式等关键要点 | 仅汇总用户已明确的事实与结论,最终结果不得保留待确认项 |
|
||||
| strategicSignificance | 任务对后续岗位协同、业务链或决策推进的意义 | 必须分析该任务将支撑哪类后续决策、业务链协同或资源投入;可以作出任务级合理推断,但不得虚构组织现状或业务数据 |
|
||||
|
||||
核心原则:
|
||||
|
||||
- 用户已给出任务目标、范围、用途和约束时,直接汇总,不要追问。
|
||||
- “只给出一个主题或一句极短诉求”不等于任务意图完整。任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助。
|
||||
- 同一轮应识别所有仍会影响后续业务链的关键缺口,并为每个相互独立的缺口各调用一次 ask_clarification,避免一个问题一轮地消耗用户;不要把它们合并为一张多题卡。
|
||||
- 最终 [INTENT_READY] 中禁止出现“待确认”“未明确”“需补充”“暂无”“缺少”等未完成表述;信息不足时继续协助,不得提前交接。
|
||||
- 空数组只适用于用户明确说明“不涉及/没有”这一项时。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流
|
||||
|
||||
~~~
|
||||
用户输入
|
||||
↓
|
||||
是否能形成可执行的四项意图?
|
||||
├─ 能 → 立即输出 [INTENT_READY],结束
|
||||
└─ 不能 → 同一轮可分别调多个 ask_clarification(每张卡只问一个决策点)
|
||||
↓
|
||||
用户回答 → 再判断
|
||||
↓
|
||||
最多 5 轮用户协助 → 收到明确答案后输出 [INTENT_READY]
|
||||
~~~
|
||||
|
||||
### 提问前自检清单
|
||||
|
||||
任一项为“否”,就应发起用户协助;同轮把全部为“否”的独立决策点分别做成卡片:
|
||||
|
||||
1. 我是否不仅能写出核心目标,也能明确主要分析范围或决策用途?
|
||||
2. 我是否能基于任务给出至少一项任务级风险提示和战略意义?
|
||||
3. 用户是否已经在任务卡、当前消息或历史消息中给出这些关键信息?
|
||||
4. 这些缺口是否已经在本轮协助中一次性列出?
|
||||
5. 是否已达到第 5 次用户协助?若是,等待用户对最后一组问题的回答,不再发起第 6 次协助。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一允许的工具:ask_clarification
|
||||
|
||||
除 ask_clarification 外,禁止调用任何工具、技能或操作。尤其禁止搜索、写文件、读写沙箱、展示文件、创建任务、编排智能体或生成交付物。
|
||||
|
||||
当任务只有主题、或缺少决定范围/用途/风险判断的关键信息时,必须调用它。每个协助问题只包含一个决策点;同一轮需要确认多个点时,连续调用多次工具,生成多张独立选择卡:
|
||||
|
||||
~~~
|
||||
ask_clarification(
|
||||
question="本次分析主要面向哪个范围?",
|
||||
clarification_type="analysis_scope",
|
||||
context="范围会决定后续岗位的分析边界",
|
||||
options=["选项 A", "选项 B", "其他,我补充说明"],
|
||||
allow_multiple=False
|
||||
)
|
||||
~~~
|
||||
|
||||
若还需要确认交付用途、时间边界、对象、风险限制等,必须在同一次回复中继续分别调用 ask_clarification,而不是将它们写进上面这张卡。不得重复询问用户已经回答过的问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 结束协议:[INTENT_READY]
|
||||
|
||||
可以收敛时,先用 1–2 句话、30 字以内复述理解,然后严格以 JSON 代码块结束。JSON 代码块后不得有任何内容。
|
||||
|
||||
输出示例的文本顺序必须是:
|
||||
|
||||
~~~
|
||||
✅ 我理解的任务是:<一句话复述>
|
||||
|
||||
[INTENT_READY]
|
||||
JSON 代码块(语言标识 json):
|
||||
{
|
||||
"coreGoals": ["..."],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": ["..."],
|
||||
"strategicSignificance": []
|
||||
}
|
||||
~~~
|
||||
|
||||
格式要求:
|
||||
|
||||
- [INTENT_READY] 单独成行。
|
||||
- [INTENT_READY] 后紧跟语言标识为 json 的 Markdown 代码块,包裹合法 JSON 对象。
|
||||
- 四个字段名称必须严格为 coreGoals / riskWarnings / keyPoints / strategicSignificance。
|
||||
- 四个字段必须都是字符串数组;除用户明确说明不涉及外,不要输出空数组。
|
||||
- 输出前逐项检查:四个数组中不得出现“待确认”“未明确”“需补充”“暂无”“缺少”等词语。
|
||||
- JSON 必须严格合法:英文双引号、无注释、无尾随逗号;字符串中不可直接换行。
|
||||
- JSON 代码块闭合后,禁止再写任何字符。
|
||||
- 不得输出旧字段 objective、constraints、assumptions。
|
||||
|
||||
---
|
||||
|
||||
## 5. 禁止事项
|
||||
|
||||
- 禁止直接产出报告、代码、诗、方案、分析或任何交付物。
|
||||
- 禁止调用任何非 ask_clarification 的工具。
|
||||
- 禁止第 5 次用户协助后再发起第 6 次协助。
|
||||
- 禁止重复询问用户已回答的问题。
|
||||
- 禁止拒绝用户、要求用户换问题或以风险为由中断。
|
||||
- 禁止编造外部事实、组织现状或业务数据;信息缺口必须在用户协助阶段确认,最终只输出明确的执行风险和战略意义。
|
||||
- 禁止在 [INTENT_READY] 后再输出一个字。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
id: position-roundtable-intent
|
||||
name: 岗位会商任务意图理解
|
||||
description: 岗位会商专用的任务意图理解智能体。通过必要的简短追问,将任务整理为核心目标、风险提示、关键要点与战略意义,供后续岗位业务链执行。
|
||||
@@ -34,6 +34,7 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
多卡回合还带 ``clarifications`` 完整数组;顶层字段始终镜像第一张卡以兼容旧客户端。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
@@ -65,7 +66,10 @@ from app.gateway.roundtable_diag import record_foreground_diag
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
_auth_headers,
|
||||
@@ -87,6 +91,7 @@ router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
|
||||
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
@@ -96,7 +101,49 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
|
||||
"""Resolve the explicitly supported Step-1 agent without opening a route
|
||||
for arbitrary custom-agent execution.
|
||||
|
||||
Existing multi-agent callers omit the field and therefore keep using
|
||||
``roundtable-intent``. Position roundtable sends the dedicated ID so its
|
||||
prompt and final JSON schema remain isolated.
|
||||
"""
|
||||
requested = str(payload.get("intent_agent_id") or "").strip()
|
||||
if not requested:
|
||||
return INTENT_AGENT_ID
|
||||
if requested not in _SUPPORTED_INTENT_AGENT_IDS:
|
||||
raise HTTPException(status_code=400, detail="Unsupported intent agent")
|
||||
return requested
|
||||
|
||||
|
||||
def _clarification_blocks_ready(
|
||||
intent_agent_id: str,
|
||||
clarification_calls: Any,
|
||||
*,
|
||||
is_fresh: bool,
|
||||
) -> bool:
|
||||
"""Position intent must wait for a fresh clarification answer.
|
||||
|
||||
The generic Step-1 route intentionally preserves READY-first behavior for
|
||||
stale tool calls in historical snapshots. Position roundtable instead has
|
||||
a four-part hand-off whose missing context is requested interactively, so
|
||||
a newly dispatched clarification is a strict terminal pause. A historical
|
||||
tool call from the preceding user-assistance turn must not block the next
|
||||
user answer from reaching READY.
|
||||
"""
|
||||
return (
|
||||
intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
and bool(clarification_calls)
|
||||
and is_fresh
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(
|
||||
task_context: dict[str, Any] | None,
|
||||
*,
|
||||
intent_agent_id: str = INTENT_AGENT_ID,
|
||||
) -> dict[str, Any]:
|
||||
"""Build task-scoped metadata for a newly-created intent thread.
|
||||
|
||||
Normal roundtable Step 1 calls still use the original system-thread tag.
|
||||
@@ -122,6 +169,8 @@ def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, An
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
||||
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -190,6 +239,32 @@ def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _count_past_clarification_rounds(messages: list[dict[str, Any]]) -> int:
|
||||
"""Count completed assistance *rounds*, rather than question cards.
|
||||
|
||||
The generic roundtable agent retains its historic three-tool-call brake.
|
||||
Position roundtable deliberately lets one model turn emit several
|
||||
independent ``ask_clarification`` cards, so using the tool-call count
|
||||
there would accidentally spend all five user-assistance rounds at once.
|
||||
One AI message containing one or more clarification calls is one round.
|
||||
"""
|
||||
if not isinstance(messages, list):
|
||||
return 0
|
||||
rounds = 0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
if any(
|
||||
(tc.get("name") or "").lower() == "ask_clarification"
|
||||
for tc in msg.get("tool_calls") or []
|
||||
if isinstance(tc, dict)
|
||||
):
|
||||
rounds += 1
|
||||
return rounds
|
||||
|
||||
|
||||
async def _fetch_thread_messages(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
@@ -225,8 +300,18 @@ async def _fetch_thread_messages(
|
||||
|
||||
|
||||
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
|
||||
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
|
||||
# 通用会商沿用 3 次工具调用的历史保护;岗位会商按"一轮可多张卡"计数,
|
||||
# 允许最多 5 次用户协助。
|
||||
_MAX_CLARIFICATION_ROUNDS = 3
|
||||
_POSITION_MAX_CLARIFICATION_ROUNDS = 5
|
||||
|
||||
|
||||
def _max_clarification_rounds(intent_agent_id: str) -> int:
|
||||
return (
|
||||
_POSITION_MAX_CLARIFICATION_ROUNDS
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else _MAX_CLARIFICATION_ROUNDS
|
||||
)
|
||||
|
||||
|
||||
def _is_repeated_clarification(
|
||||
@@ -310,6 +395,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
|
||||
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
|
||||
ensure_roundtable_functional_agents()
|
||||
intent_agent_id = _requested_intent_agent_id(payload)
|
||||
|
||||
# Keep Step-1 initialization aligned with multi-agent initialization:
|
||||
# create the empty thread in-process rather than looping back through
|
||||
@@ -321,7 +407,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
try:
|
||||
thread_id = await _create_thread_direct(
|
||||
request,
|
||||
metadata=_intent_thread_metadata(task_context),
|
||||
metadata=_intent_thread_metadata(task_context, intent_agent_id=intent_agent_id),
|
||||
)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
@@ -341,7 +427,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
raise HTTPException(status_code=500, detail=diagnostic) from exc
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
"agent_id": intent_agent_id,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
@@ -363,6 +449,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
}
|
||||
"""
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
intent_agent_id = _requested_intent_agent_id(payload)
|
||||
message = str(payload.get("message") or "").strip()
|
||||
# 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
||||
@@ -386,21 +473,30 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
fallback_instruction = (
|
||||
"四个岗位会商字段都必须保留为字符串数组,依据已获得的用户确认形成明确结论,"
|
||||
"不得写待确认、未明确、需补充、暂无或缺少"
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else "缺的字段用 assumptions 兜底"
|
||||
)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
|
||||
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# —— 3 轮硬上限兜底 ——
|
||||
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
|
||||
# past=0/1 → 正常,不动 message,不动工具白名单
|
||||
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
|
||||
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
|
||||
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
|
||||
past_clarifications = _count_past_clarifications(
|
||||
await _fetch_thread_messages(client, base, headers, thread_id)
|
||||
# —— 澄清轮次硬上限兜底 ——
|
||||
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
|
||||
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
|
||||
# "提问 + 用户回答")计轮,不能按卡片数计轮。
|
||||
past_messages = await _fetch_thread_messages(client, base, headers, thread_id)
|
||||
past_clarifications = _count_past_clarifications(past_messages)
|
||||
past_rounds = (
|
||||
_count_past_clarification_rounds(past_messages)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else past_clarifications
|
||||
)
|
||||
max_rounds = _max_clarification_rounds(intent_agent_id)
|
||||
|
||||
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
|
||||
base_excluded_tools = [
|
||||
@@ -420,13 +516,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
"task",
|
||||
"write_todos",
|
||||
]
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
if past_rounds >= max_rounds:
|
||||
base_excluded_tools.append("ask_clarification")
|
||||
logger.info(
|
||||
"intent stream: past_clarifications=%d >= %d, force-disabling "
|
||||
"intent stream: past_rounds=%d >= %d, force-disabling "
|
||||
"ask_clarification to break the loop",
|
||||
past_clarifications,
|
||||
_MAX_CLARIFICATION_ROUNDS,
|
||||
past_rounds,
|
||||
max_rounds,
|
||||
)
|
||||
|
||||
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
|
||||
@@ -440,22 +536,42 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
||||
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
|
||||
# The position flow needs an actionable four-part hand-off,
|
||||
# not merely a recognizable topic. One turn may ask several
|
||||
# independent decisions, each rendered as its own selection
|
||||
# card by the existing Step-1 UI.
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
|
||||
"[岗位会商收敛检查] 仅识别出主题不等于意图已明确。"
|
||||
"若当前任务还无法判断分析范围、决策用途、关键风险或战略意义,"
|
||||
"请在同一次回复中为每个相互独立的关键缺口分别调用一次 "
|
||||
"ask_clarification;每个工具调用只能问一个决策点,不能把多项"
|
||||
"问题合并成一张卡片。用户会一次性回答这些卡片。"
|
||||
"如果四项意图已经足够明确,则直接输出最终结果。"
|
||||
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
|
||||
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
|
||||
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
|
||||
f"用户最新输入:{effective_message}"
|
||||
) % (past_rounds + 1, max_rounds, max_rounds)
|
||||
if past_rounds >= max_rounds:
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_rounds} 轮,触发硬上限。"
|
||||
f"本轮请直接输出 [INTENT_READY],{fallback_instruction},"
|
||||
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
|
||||
elif (
|
||||
intent_agent_id != POSITION_INTENT_AGENT_ID
|
||||
and past_rounds >= max_rounds - 1
|
||||
):
|
||||
effective_message = (
|
||||
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
|
||||
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
|
||||
f"[系统提示] 已澄清 {past_rounds} 轮,这是最后一轮。"
|
||||
f"本轮请直接输出 [INTENT_READY],{fallback_instruction}。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
agent_name=intent_agent_id,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=effective_message,
|
||||
@@ -479,6 +595,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
current_rounds = (
|
||||
_count_past_clarification_rounds(messages)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else _count_past_clarifications(messages)
|
||||
)
|
||||
clarification_is_fresh = current_rounds > past_rounds
|
||||
|
||||
# A position-roundtable clarification is an explicit pause.
|
||||
# Some models emit a premature [INTENT_READY] text after the
|
||||
# tool call in the same upstream run. For this dedicated flow
|
||||
# the fresh clarification must win so the user can answer it;
|
||||
# generic roundtable keeps its historical READY-first order.
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
clarification_calls = [
|
||||
tc
|
||||
for tc in tool_calls
|
||||
if isinstance(tc, dict)
|
||||
and (tc.get("name") or "").lower() == "ask_clarification"
|
||||
]
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
@@ -507,7 +643,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
|
||||
ready_text = msg_text[: m.end()].rstrip() + "\n"
|
||||
break
|
||||
if summary and ready_text:
|
||||
if (
|
||||
summary
|
||||
and ready_text
|
||||
and not _clarification_blocks_ready(
|
||||
intent_agent_id,
|
||||
clarification_calls,
|
||||
is_fresh=clarification_is_fresh,
|
||||
)
|
||||
):
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
@@ -519,42 +663,49 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
if clarification_calls:
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
|
||||
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
|
||||
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
|
||||
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
|
||||
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
clarification_payloads: list[dict[str, Any]] = []
|
||||
for clarification_call in clarification_calls:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# 逐卡过滤已被用户回答过的重复问题,不能因为其中一张
|
||||
# 重复卡而吞掉同轮其它有效的协助卡。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
)
|
||||
continue
|
||||
clarification_payloads.append(
|
||||
{
|
||||
"id": str(clarification_call.get("id") or ""),
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
}
|
||||
)
|
||||
|
||||
if not clarification_payloads:
|
||||
fallback_msg = (
|
||||
display_content
|
||||
follow_up
|
||||
or content
|
||||
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
|
||||
)
|
||||
final_frame: dict[str, Any] = {
|
||||
final_frame = {
|
||||
"status": "asking",
|
||||
"content": fallback_msg,
|
||||
"summary": None,
|
||||
@@ -562,15 +713,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
first = clarification_payloads[0]
|
||||
display_content = follow_up or content or str(first["question"])
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
# 兼容旧客户端保留首张卡的顶层字段;完整列表使得网络
|
||||
# 重连、缓存重放等没有实时 tool frame 的路径也能还原多卡。
|
||||
**first,
|
||||
"clarifications": clarification_payloads,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Administrator model configuration backed by DeerFlow's ``config.yaml``.
|
||||
|
||||
The public ``/api/models`` endpoint intentionally stays small and never
|
||||
returns secrets. This router is the complementary administrator surface: it
|
||||
edits only the top-level ``models:`` section in the active DeerFlow config,
|
||||
keeps a disk backup before every write, and swaps the gateway's runtime config
|
||||
after validation so new requests can use the change without a restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.config.app_config import AppConfig, reload_app_config
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
from deerflow.models.factory import create_chat_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/models", tags=["admin-models"])
|
||||
|
||||
_MODEL_BLOCK_RE = re.compile(r"(?m)^models:[^\r\n]*(?:\r?\n|$)")
|
||||
_TOP_LEVEL_KEY_RE = re.compile(r"(?m)^[A-Za-z_][A-Za-z0-9_-]*:[^\r\n]*(?:\r?\n|$)")
|
||||
_BACKUP_DIR_NAME = ".model-config-backups"
|
||||
_MUTATION_LOCK = asyncio.Lock()
|
||||
|
||||
_EDITOR_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"display_name",
|
||||
"description",
|
||||
"use",
|
||||
"model",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"request_timeout",
|
||||
"max_retries",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"stream_usage",
|
||||
"supports_thinking",
|
||||
"supports_reasoning_effort",
|
||||
"supports_vision",
|
||||
"when_thinking_enabled",
|
||||
"when_thinking_disabled",
|
||||
"thinking",
|
||||
"use_responses_api",
|
||||
"output_version",
|
||||
}
|
||||
)
|
||||
_PRESERVED_FIELDS = frozenset(ModelConfig.model_fields) | _EDITOR_FIELDS
|
||||
|
||||
|
||||
class ModelAdminItem(BaseModel):
|
||||
"""A model configuration safe to return to an administrator browser."""
|
||||
|
||||
name: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
use: str
|
||||
model: str
|
||||
base_url: str | None = None
|
||||
request_timeout: float | None = None
|
||||
max_retries: int | None = None
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
stream_usage: bool | None = None
|
||||
supports_thinking: bool = False
|
||||
supports_reasoning_effort: bool = False
|
||||
supports_vision: bool = False
|
||||
when_thinking_enabled: dict[str, Any] | None = None
|
||||
when_thinking_disabled: dict[str, Any] | None = None
|
||||
thinking: dict[str, Any] | None = None
|
||||
use_responses_api: bool | None = None
|
||||
output_version: str | None = None
|
||||
extra_settings: dict[str, Any] = Field(default_factory=dict)
|
||||
api_key_configured: bool = False
|
||||
api_key_source: str | None = None
|
||||
source: str = "config.yaml"
|
||||
|
||||
|
||||
class ModelAdminListResponse(BaseModel):
|
||||
models: list[ModelAdminItem]
|
||||
config_path: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ModelUpsertRequest(BaseModel):
|
||||
"""Editable subset of the ``ModelConfig`` schema plus safe extra fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
display_name: str | None = Field(default=None, max_length=160)
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
use: str = Field(min_length=1, max_length=255)
|
||||
model: str = Field(min_length=1, max_length=255)
|
||||
# ``None`` means "keep the currently configured secret" on update.
|
||||
# The value is write-only in practice: it is never part of a response.
|
||||
api_key: str | None = Field(default=None, max_length=4096)
|
||||
clear_api_key: bool = False
|
||||
base_url: str | None = Field(default=None, max_length=2048)
|
||||
request_timeout: float | None = Field(default=None, ge=1, le=600)
|
||||
max_retries: int | None = Field(default=None, ge=0, le=20)
|
||||
max_tokens: int | None = Field(default=None, ge=1, le=1_000_000)
|
||||
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||
stream_usage: bool | None = None
|
||||
supports_thinking: bool = False
|
||||
supports_reasoning_effort: bool = False
|
||||
supports_vision: bool = False
|
||||
when_thinking_enabled: dict[str, Any] | None = None
|
||||
when_thinking_disabled: dict[str, Any] | None = None
|
||||
thinking: dict[str, Any] | None = None
|
||||
use_responses_api: bool | None = None
|
||||
output_version: str | None = Field(default=None, max_length=128)
|
||||
extra_settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("name", "use", "model")
|
||||
@classmethod
|
||||
def _trim_required(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("value cannot be empty")
|
||||
return normalized
|
||||
|
||||
@field_validator("display_name", "description", "base_url", "api_key", "output_version")
|
||||
@classmethod
|
||||
def _trim_optional(cls, value: str | None) -> str | None:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
class ModelTestRequest(BaseModel):
|
||||
model: ModelUpsertRequest
|
||||
|
||||
|
||||
class ModelTestResponse(BaseModel):
|
||||
success: bool
|
||||
latency_ms: int
|
||||
message: str
|
||||
preview: str | None = None
|
||||
|
||||
|
||||
def _mask_env_source(value: Any) -> str | None:
|
||||
if isinstance(value, str) and value.startswith("$") and len(value) > 1:
|
||||
return value[1:]
|
||||
return None
|
||||
|
||||
|
||||
def _to_admin_item(raw: dict[str, Any]) -> ModelAdminItem:
|
||||
api_key = raw.get("api_key")
|
||||
extra = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in raw.items()
|
||||
if key not in _PRESERVED_FIELDS
|
||||
}
|
||||
return ModelAdminItem(
|
||||
name=str(raw.get("name") or ""),
|
||||
display_name=raw.get("display_name"),
|
||||
description=raw.get("description"),
|
||||
use=str(raw.get("use") or ""),
|
||||
model=str(raw.get("model") or ""),
|
||||
base_url=raw.get("base_url"),
|
||||
request_timeout=raw.get("request_timeout"),
|
||||
max_retries=raw.get("max_retries"),
|
||||
max_tokens=raw.get("max_tokens"),
|
||||
temperature=raw.get("temperature"),
|
||||
stream_usage=raw.get("stream_usage"),
|
||||
supports_thinking=bool(raw.get("supports_thinking", False)),
|
||||
supports_reasoning_effort=bool(raw.get("supports_reasoning_effort", False)),
|
||||
supports_vision=bool(raw.get("supports_vision", False)),
|
||||
when_thinking_enabled=copy.deepcopy(raw.get("when_thinking_enabled")),
|
||||
when_thinking_disabled=copy.deepcopy(raw.get("when_thinking_disabled")),
|
||||
thinking=copy.deepcopy(raw.get("thinking")),
|
||||
use_responses_api=raw.get("use_responses_api"),
|
||||
output_version=raw.get("output_version"),
|
||||
extra_settings=extra,
|
||||
api_key_configured=bool(api_key),
|
||||
api_key_source=_mask_env_source(api_key),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_config_path() -> Path:
|
||||
return AppConfig.resolve_config_path()
|
||||
|
||||
|
||||
def _read_config_data(path: Path) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
|
||||
raw_text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw_text) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Unable to parse active config.yaml: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status_code=500, detail="The active config.yaml root must be a mapping")
|
||||
raw_models = data.get("models") or []
|
||||
if not isinstance(raw_models, list) or not all(isinstance(item, dict) for item in raw_models):
|
||||
raise HTTPException(status_code=500, detail="config.yaml models must be a list of mappings")
|
||||
return raw_text, data, [copy.deepcopy(item) for item in raw_models]
|
||||
|
||||
|
||||
def _render_models_block(models: list[dict[str, Any]], newline: str) -> str:
|
||||
rendered = yaml.safe_dump(
|
||||
{"models": models},
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
width=120,
|
||||
)
|
||||
if newline != "\n":
|
||||
rendered = rendered.replace("\n", newline)
|
||||
return rendered if rendered.endswith(newline) else f"{rendered}{newline}"
|
||||
|
||||
|
||||
def _replace_models_block(raw_text: str, models: list[dict[str, Any]]) -> str:
|
||||
"""Replace the active model entries without touching other config sections.
|
||||
|
||||
PyYAML does not retain comments. Keep every comment found in the old
|
||||
model section as documentation after the newly rendered model list rather
|
||||
than silently deleting an operator's notes or disabled examples.
|
||||
"""
|
||||
|
||||
block_match = _MODEL_BLOCK_RE.search(raw_text)
|
||||
if block_match is None:
|
||||
raise HTTPException(status_code=500, detail="The active config.yaml has no top-level models section")
|
||||
newline = "\r\n" if "\r\n" in raw_text else "\n"
|
||||
next_key = _TOP_LEVEL_KEY_RE.search(raw_text, block_match.end())
|
||||
end = next_key.start() if next_key else len(raw_text)
|
||||
previous_section = raw_text[block_match.end() : end]
|
||||
preserved_comments = "".join(
|
||||
line
|
||||
for line in previous_section.splitlines(keepends=True)
|
||||
if line.lstrip().startswith("#")
|
||||
)
|
||||
rendered = _render_models_block(models, newline)
|
||||
if preserved_comments:
|
||||
if not preserved_comments.endswith(("\n", "\r")):
|
||||
preserved_comments = f"{preserved_comments}{newline}"
|
||||
rendered = f"{rendered}{preserved_comments}"
|
||||
return f"{raw_text[:block_match.start()]}{rendered}{raw_text[end:]}"
|
||||
|
||||
|
||||
def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Path:
|
||||
backup_dir = path.parent / _BACKUP_DIR_NAME
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
backup_path = backup_dir / f"{path.stem}.{stamp}.yaml"
|
||||
# Persist the exact text that was read for this mutation. This makes the
|
||||
# rollback deterministic even if another operator changes the file while
|
||||
# the gateway is processing a request.
|
||||
with backup_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(raw_before)
|
||||
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(raw_after)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_name, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return backup_path
|
||||
|
||||
|
||||
def _restore_backup(path: Path, backup_path: Path) -> None:
|
||||
try:
|
||||
shutil.copy2(backup_path, path)
|
||||
except Exception:
|
||||
logger.exception("Failed to restore model config backup at %s", backup_path)
|
||||
|
||||
|
||||
def _request_to_raw_model(payload: ModelUpsertRequest, existing: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
merged = copy.deepcopy(existing or {})
|
||||
supplied = payload.model_dump(exclude={"extra_settings", "clear_api_key"}, exclude_none=True)
|
||||
merged.update(supplied)
|
||||
for key, value in payload.extra_settings.items():
|
||||
normalized = str(key).strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=422, detail="Extra setting names cannot be blank")
|
||||
if normalized in _EDITOR_FIELDS or normalized in {"api_key", "clear_api_key", "extra_settings"}:
|
||||
raise HTTPException(status_code=422, detail=f"Extra setting '{normalized}' duplicates a managed field")
|
||||
merged[normalized] = copy.deepcopy(value)
|
||||
if payload.clear_api_key:
|
||||
merged.pop("api_key", None)
|
||||
elif payload.api_key is None and existing is not None and "api_key" in existing:
|
||||
merged["api_key"] = existing["api_key"]
|
||||
|
||||
# Validate the raw schema without resolving `$ENV_VAR` values. Environment
|
||||
# resolution happens during the post-write runtime reload and is rolled back
|
||||
# if unavailable.
|
||||
try:
|
||||
ModelConfig.model_validate(merged)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
|
||||
return merged
|
||||
|
||||
|
||||
def _redact_text(value: str, secrets: list[str]) -> str:
|
||||
result = value
|
||||
for secret in secrets:
|
||||
if secret:
|
||||
result = result.replace(secret, "***")
|
||||
return result[:800]
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication is required")
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Model configuration requires an administrator")
|
||||
|
||||
|
||||
async def _save_models(request: Request, raw_before: str, models: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
path = _resolve_config_path()
|
||||
raw_after = _replace_models_block(raw_before, models)
|
||||
backup_path = await asyncio.to_thread(_backup_and_write_config, path, raw_before, raw_after)
|
||||
try:
|
||||
config = reload_app_config(str(path))
|
||||
request.app.state.config = config
|
||||
except Exception as exc:
|
||||
logger.warning("Updated model configuration could not be activated: %s", type(exc).__name__)
|
||||
await asyncio.to_thread(_restore_backup, path, backup_path)
|
||||
try:
|
||||
request.app.state.config = reload_app_config(str(path))
|
||||
except Exception:
|
||||
logger.exception("Failed to reload restored model config")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="The model configuration could not be activated; the previous config was restored.",
|
||||
) from exc
|
||||
return models
|
||||
|
||||
|
||||
@router.get("", response_model=ModelAdminListResponse)
|
||||
async def list_model_configurations(request: Request) -> ModelAdminListResponse:
|
||||
await _require_admin(request)
|
||||
path = _resolve_config_path()
|
||||
_, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
updated_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC).isoformat()
|
||||
return ModelAdminListResponse(
|
||||
models=[_to_admin_item(item) for item in models],
|
||||
config_path=str(path),
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ModelAdminItem, status_code=201)
|
||||
async def create_model_configuration(body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
|
||||
await _require_admin(request)
|
||||
async with _MUTATION_LOCK:
|
||||
path = _resolve_config_path()
|
||||
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
if any(str(item.get("name")) == body.name for item in models):
|
||||
raise HTTPException(status_code=409, detail=f"A model named '{body.name}' already exists")
|
||||
created = _request_to_raw_model(body)
|
||||
await _save_models(request, raw_before, [*models, created])
|
||||
return _to_admin_item(created)
|
||||
|
||||
|
||||
@router.put("/{model_name:path}", response_model=ModelAdminItem)
|
||||
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
|
||||
await _require_admin(request)
|
||||
if body.name != model_name:
|
||||
raise HTTPException(status_code=422, detail="A model's stable name cannot be changed; edit its display name instead")
|
||||
async with _MUTATION_LOCK:
|
||||
path = _resolve_config_path()
|
||||
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
|
||||
if index is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
|
||||
updated = _request_to_raw_model(body, models[index])
|
||||
next_models = [*models]
|
||||
next_models[index] = updated
|
||||
await _save_models(request, raw_before, next_models)
|
||||
return _to_admin_item(updated)
|
||||
|
||||
|
||||
@router.delete("/{model_name:path}", status_code=204)
|
||||
async def delete_model_configuration(model_name: str, request: Request) -> None:
|
||||
"""Delete one model configuration and activate the remaining model list."""
|
||||
|
||||
await _require_admin(request)
|
||||
async with _MUTATION_LOCK:
|
||||
path = _resolve_config_path()
|
||||
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
|
||||
if index is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
|
||||
await _save_models(request, raw_before, [*models[:index], *models[index + 1 :]])
|
||||
|
||||
|
||||
@router.post("/test", response_model=ModelTestResponse)
|
||||
async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse:
|
||||
"""Perform one bounded minimal completion without persisting the draft."""
|
||||
|
||||
await _require_admin(request)
|
||||
path = _resolve_config_path()
|
||||
_, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
existing = next((item for item in models if str(item.get("name")) == body.model.name), None)
|
||||
candidate = _request_to_raw_model(body.model, existing)
|
||||
candidate_secret = str(candidate.get("api_key") or "")
|
||||
secrets = [candidate_secret]
|
||||
try:
|
||||
runtime_candidate = AppConfig.resolve_env_variables(copy.deepcopy(candidate))
|
||||
# An environment-variable API key is resolved only in memory for this
|
||||
# request. Redact both forms because provider exceptions can include
|
||||
# either the original `$NAME` reference or its resolved value.
|
||||
resolved_secret = str(runtime_candidate.get("api_key") or "")
|
||||
secrets = [candidate_secret, resolved_secret]
|
||||
model_config = ModelConfig.model_validate(runtime_candidate)
|
||||
base_config = request.app.state.config
|
||||
test_config = base_config.model_copy(update={"models": [model_config]})
|
||||
chat_model = create_chat_model(model_config.name, app_config=test_config, force_disable_thinking=True)
|
||||
started = perf_counter()
|
||||
response = await asyncio.wait_for(
|
||||
chat_model.ainvoke([HumanMessage(content="Reply with exactly: OK")]),
|
||||
timeout=min(float(candidate.get("request_timeout") or 30), 45),
|
||||
)
|
||||
latency_ms = round((perf_counter() - started) * 1000)
|
||||
preview = _redact_text(str(getattr(response, "content", "OK")), secrets)
|
||||
return ModelTestResponse(success=True, latency_ms=latency_ms, message="Connection successful", preview=preview)
|
||||
except Exception as exc:
|
||||
# Provider errors sometimes echo a credential. Never log the full
|
||||
# exception or return it without redacting the submitted key.
|
||||
logger.warning("Model connection test failed for '%s': %s", candidate.get("name"), type(exc).__name__)
|
||||
return ModelTestResponse(
|
||||
success=False,
|
||||
latency_ms=0,
|
||||
message=_redact_text(str(exc) or type(exc).__name__, secrets),
|
||||
)
|
||||
@@ -15,8 +15,8 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -79,7 +79,10 @@ class NodeResponse(BaseModel):
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
# Older personal rows can already contain a mock task id; task_scoped is
|
||||
# the authoritative opt-in marker for cross-user task sharing.
|
||||
external_task_id: str | None = None
|
||||
task_scoped: bool = False
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
chain_id: str | None = None
|
||||
chain_snapshot: dict[str, Any] | None = None
|
||||
@@ -105,6 +108,14 @@ class SessionCreateRequest(BaseModel):
|
||||
external_task_id: str | None = Field(default=None, max_length=128)
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("external_task_id")
|
||||
@classmethod
|
||||
def _normalize_external_task_id(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
class TaskSnapshotUpdateRequest(BaseModel):
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -145,10 +156,33 @@ class RejectNodeRequest(BaseModel):
|
||||
reason: str = Field(default="", max_length=5_000)
|
||||
|
||||
|
||||
class ActionPlanDictionaryOption(BaseModel):
|
||||
"""One task-system dictionary row, preserving dictValue as the model input."""
|
||||
|
||||
label: str = Field(default="", max_length=256)
|
||||
value: str | int | float = ""
|
||||
id: str | int | None = None
|
||||
dict_type: str = Field(default="", max_length=128)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ActionPlanDictionaries(BaseModel):
|
||||
"""The four action fields that must be selected from task-system dictionaries."""
|
||||
|
||||
publish_channels: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
action_types: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
info_formats: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
responsible_positions: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PrepareBuiltinRunRequest(BaseModel):
|
||||
"""Optional follow-up instruction for one persisted built-in agent."""
|
||||
|
||||
message: str = Field(default="", max_length=20_000)
|
||||
# 岗位会商会话允许本地演示使用非数字 taskId。真实任务系统新增行动
|
||||
# 时由专属 Skill 再按外部接口契约校验正整数,不能在 prepare 阶段阻断报告生成。
|
||||
task_id: str | int | None = Field(default=None, max_length=128)
|
||||
action_dictionaries: ActionPlanDictionaries | None = None
|
||||
|
||||
|
||||
class BuiltinRunPreparationResponse(BaseModel):
|
||||
@@ -180,6 +214,25 @@ def _current_user_id(request: Request) -> str:
|
||||
return str(user.id) if user is not None else get_effective_user_id()
|
||||
|
||||
|
||||
def _task_snapshot_for_scope(
|
||||
external_task_id: str | None,
|
||||
task_snapshot: dict[str, Any],
|
||||
*,
|
||||
task_scoped: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Lock the snapshot id only for an explicit shared task scope."""
|
||||
if not task_scoped or not external_task_id:
|
||||
return dict(task_snapshot)
|
||||
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
|
||||
|
||||
|
||||
def _normalize_external_task_id(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "position_roundtable_store", None)
|
||||
if store is None:
|
||||
@@ -440,15 +493,64 @@ def _markdown_delivery_changed(
|
||||
return bool(new_signatures - old_signatures)
|
||||
|
||||
|
||||
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
|
||||
"""Return a clean, display-safe string list from a persisted intent field."""
|
||||
value = intent.get(key)
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
||||
|
||||
|
||||
def _position_intent_sections(intent: Any) -> dict[str, list[str]]:
|
||||
"""Read both the dedicated position-intent schema and legacy snapshots.
|
||||
|
||||
New position sessions persist the four position-specific fields. Mapping
|
||||
the legacy Step-1 fields keeps already-created sessions executable after
|
||||
this rollout instead of stranding them at the activation gate.
|
||||
"""
|
||||
if not isinstance(intent, dict):
|
||||
return {
|
||||
"coreGoals": [],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": [],
|
||||
"strategicSignificance": [],
|
||||
}
|
||||
has_position_schema = any(
|
||||
key in intent
|
||||
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
)
|
||||
if has_position_schema:
|
||||
return {
|
||||
"coreGoals": _intent_string_list(intent, "coreGoals"),
|
||||
"riskWarnings": _intent_string_list(intent, "riskWarnings"),
|
||||
"keyPoints": _intent_string_list(intent, "keyPoints"),
|
||||
"strategicSignificance": _intent_string_list(intent, "strategicSignificance"),
|
||||
}
|
||||
|
||||
objective = _text_from_keys(intent, ["objective", "goal"])
|
||||
return {
|
||||
"coreGoals": _intent_string_list(intent, "constraints"),
|
||||
"riskWarnings": [],
|
||||
"keyPoints": _intent_string_list(intent, "assumptions"),
|
||||
"strategicSignificance": [objective] if objective else [],
|
||||
}
|
||||
|
||||
|
||||
def _intent_node_answer(intent: Any) -> str:
|
||||
if not isinstance(intent, dict):
|
||||
return "情报分析岗已完成任务意图识别。"
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
|
||||
if constraints:
|
||||
lines.append("关键约束:")
|
||||
lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
sections = _position_intent_sections(intent)
|
||||
lines = ["情报分析岗已完成任务意图识别。"]
|
||||
labels = (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
)
|
||||
for label, values in labels:
|
||||
if values:
|
||||
lines.append(f"{label}:")
|
||||
lines.extend(f"- {item}" for item in values)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -456,6 +558,15 @@ def _has_confirmed_intent(intent: Any) -> bool:
|
||||
"""Only structured Step-1 output may start a frozen business chain."""
|
||||
if not isinstance(intent, dict):
|
||||
return False
|
||||
if any(
|
||||
key in intent
|
||||
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
):
|
||||
required = ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
if not all(isinstance(intent.get(key), list) for key in required):
|
||||
return False
|
||||
sections = _position_intent_sections(intent)
|
||||
return bool(sections["coreGoals"] or sections["strategicSignificance"])
|
||||
objective = _text_from_keys(intent, ["objective", "goal"])
|
||||
constraints = intent.get("constraints")
|
||||
return bool(objective and isinstance(constraints, list))
|
||||
@@ -505,8 +616,7 @@ def _build_node_context_text(
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
sections = _position_intent_sections(intent)
|
||||
agent_name, position_id = _seat_meta(session, current)
|
||||
md_required = not _is_intent_position_node(current, _session_intent_position_id(session))
|
||||
lines = [
|
||||
@@ -518,11 +628,16 @@ def _build_node_context_text(
|
||||
f"- 任务描述:{description}",
|
||||
"",
|
||||
"【已识别意图】",
|
||||
f"- 任务目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
lines.append("- 关键约束:")
|
||||
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
for label, values in (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
):
|
||||
if values:
|
||||
lines.append(f"- {label}:")
|
||||
lines.extend(f" - {item}" for item in values)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
@@ -968,6 +1083,8 @@ async def _build_builtin_prompt(
|
||||
session: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
follow_up: str = "",
|
||||
action_task_id: str | int | None = None,
|
||||
action_dictionaries: ActionPlanDictionaries | None = None,
|
||||
) -> str:
|
||||
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||
@@ -975,8 +1092,7 @@ async def _build_builtin_prompt(
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
sections = _position_intent_sections(intent)
|
||||
stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
|
||||
stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
|
||||
material = await _effective_delivery_material(session, nodes)
|
||||
@@ -990,11 +1106,16 @@ async def _build_builtin_prompt(
|
||||
f"- 描述:{description}",
|
||||
"",
|
||||
"【情报分析岗已确认的意图】",
|
||||
f"- 核心目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
common.append("- 关键约束:")
|
||||
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
for label, values in (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
):
|
||||
if values:
|
||||
common.append(f"- {label}:")
|
||||
common.extend(f" - {item}" for item in values)
|
||||
if stage_lines:
|
||||
common.extend(["", "【业务链阶段】", *stage_lines])
|
||||
|
||||
@@ -1007,13 +1128,50 @@ async def _build_builtin_prompt(
|
||||
else:
|
||||
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
|
||||
summary_content = _truncate_text(summary.get("content"), 12_000)
|
||||
dictionary_groups = (
|
||||
("发布渠道(publishChannel)", action_dictionaries.publish_channels if action_dictionaries else []),
|
||||
("行动类型(actionType)", action_dictionaries.action_types if action_dictionaries else []),
|
||||
("信息形式(infoFormat)", action_dictionaries.info_formats if action_dictionaries else []),
|
||||
("责任岗位(responsiblePosition)", action_dictionaries.responsible_positions if action_dictionaries else []),
|
||||
)
|
||||
dictionaries_ready = bool(action_task_id is not None and all(options for _, options in dictionary_groups))
|
||||
dictionary_lines = [
|
||||
"【前端传入的行动字典(名称 → 必须原样使用的 dictValue)】",
|
||||
f"- 当前 taskId:{action_task_id if action_task_id is not None else '(本地预览)'}",
|
||||
]
|
||||
for label, options in dictionary_groups:
|
||||
dictionary_lines.append(f"- {label}:")
|
||||
dictionary_lines.extend(
|
||||
f" - {option.label} → {option.value}"
|
||||
for option in options
|
||||
)
|
||||
dictionary_instruction = (
|
||||
"actionType、publishChannel、infoFormat、responsiblePosition 必须从下方同名字典复制 dictValue 并写成整数;"
|
||||
"taskId 必须原样写为当前 taskId。不要保留 id、title、goal、basis、owner_position、dependencies 等旧字段,"
|
||||
"也不要用 0 或空值代替未选择的字典项。"
|
||||
if dictionaries_ready
|
||||
else "当前四类任务系统字典未获取完整,这是本地预览/接口未就绪状态。仍需生成 9 字段 JSON,"
|
||||
"但四个枚举字段可暂填 0 占位,且不得调用 position-roundtable-action-create Skill 或外部任务系统。"
|
||||
)
|
||||
submit_instruction = (
|
||||
"若当前 taskId 是正整数,首次生成完整行动清单后按行动规划智能体的 SOUL 调用 position-roundtable-action-create Skill 批量新增行动;"
|
||||
"若 taskId 为本地演示标识或为空,只生成报告和 JSON,明确说明未调用任务系统。Skill 返回失败时如实报告,不能伪称已入库。"
|
||||
if dictionaries_ready
|
||||
else "本轮字典不完整,只生成报告和 JSON 预览;不得调用 Skill、不得请求外部任务系统,也不能宣称行动已入库。"
|
||||
)
|
||||
instructions = [
|
||||
"请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。",
|
||||
"必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。",
|
||||
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。",
|
||||
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排。"
|
||||
"JSON 必须是严格合法数组;每个对象只能包含 actionName、actionDescription、actionType、"
|
||||
"publishChannel、infoFormat、responsiblePosition、startTime、endTime、taskId 这 9 个字段。",
|
||||
dictionary_instruction,
|
||||
submit_instruction,
|
||||
"",
|
||||
"【已生效方案总结】",
|
||||
summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
|
||||
"",
|
||||
*dictionary_lines,
|
||||
]
|
||||
if follow_up.strip():
|
||||
instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
|
||||
@@ -1082,9 +1240,13 @@ async def seed_demo_session(
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
|
||||
async def list_sessions(
|
||||
request: Request,
|
||||
external_task_id: str | None = Query(default=None, max_length=128),
|
||||
) -> list[SessionResponse]:
|
||||
task_id = _normalize_external_task_id(external_task_id)
|
||||
rows = await _get_store(request).list_sessions(
|
||||
_current_user_id(request), external_task_id=external_task_id
|
||||
_current_user_id(request), external_task_id=task_id
|
||||
)
|
||||
return [_project_session(row) for row in rows]
|
||||
|
||||
@@ -1094,7 +1256,12 @@ async def create_session(request: Request, body: SessionCreateRequest) -> Sessio
|
||||
row = await _get_store(request).create_session(
|
||||
_current_user_id(request),
|
||||
external_task_id=body.external_task_id,
|
||||
task_snapshot=body.task_snapshot,
|
||||
task_snapshot=_task_snapshot_for_scope(
|
||||
body.external_task_id,
|
||||
body.task_snapshot,
|
||||
task_scoped=bool(body.external_task_id),
|
||||
),
|
||||
task_scoped=bool(body.external_task_id),
|
||||
)
|
||||
return _project_session(row)
|
||||
|
||||
@@ -1108,8 +1275,16 @@ async def get_session(request: Request, session_id: str) -> SessionDetailRespons
|
||||
async def update_task_snapshot(
|
||||
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
|
||||
) -> SessionResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot)
|
||||
store, user_id, current = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
task_snapshot=_task_snapshot_for_scope(
|
||||
current.get("external_task_id"),
|
||||
body.task_snapshot,
|
||||
task_scoped=bool(current.get("task_scoped")),
|
||||
),
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
@@ -1436,6 +1611,8 @@ async def _prepare_builtin_run(
|
||||
*,
|
||||
kind: str,
|
||||
follow_up: str = "",
|
||||
action_task_id: str | int | None = None,
|
||||
action_dictionaries: ActionPlanDictionaries | None = None,
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
store, user_id, session = await _require_writable_session(request, session_id)
|
||||
nodes = await store.list_nodes(session_id, user_id)
|
||||
@@ -1469,6 +1646,8 @@ async def _prepare_builtin_run(
|
||||
session=session,
|
||||
nodes=nodes,
|
||||
follow_up=follow_up,
|
||||
action_task_id=action_task_id,
|
||||
action_dictionaries=action_dictionaries,
|
||||
),
|
||||
source_revisions=_source_revisions(nodes),
|
||||
)
|
||||
@@ -1576,14 +1755,28 @@ async def complete_summary(
|
||||
async def prepare_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
return await _prepare_builtin_run(
|
||||
request,
|
||||
session_id,
|
||||
kind="action_plan",
|
||||
follow_up=body.message,
|
||||
action_task_id=body.task_id,
|
||||
action_dictionaries=body.action_dictionaries,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse)
|
||||
async def ask_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
return await _prepare_builtin_run(
|
||||
request,
|
||||
session_id,
|
||||
kind="action_plan",
|
||||
follow_up=body.message,
|
||||
action_task_id=body.task_id,
|
||||
action_dictionaries=body.action_dictionaries,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
"""Task-scoped submission status for roundtable artifacts.
|
||||
|
||||
This API records only an artifact reference (thread + sandbox path) and its
|
||||
submission state. It intentionally does not fetch task detail, copy Markdown,
|
||||
or deliver a file to an external task system.
|
||||
|
||||
Routes (authenticated by the Gateway middleware):
|
||||
GET /api/roundtable/tasks/{task_id}/artifacts?submitted=true
|
||||
PUT /api/roundtable/tasks/{task_id}/artifacts/submission
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
|
||||
|
||||
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
|
||||
|
||||
|
||||
class ArtifactReferenceInput(BaseModel):
|
||||
"""Reference to a generated file; the file content remains in its thread sandbox."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
session_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("sessionId", "session_id"))
|
||||
thread_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("threadId", "thread_id"))
|
||||
node_key: StrictStr | None = Field(default=None, max_length=256, validation_alias=AliasChoices("nodeKey", "node_key"))
|
||||
source_key: StrictStr = Field(..., min_length=1, max_length=512, validation_alias=AliasChoices("sourceKey", "source_key"))
|
||||
path: StrictStr = Field(..., min_length=1, max_length=2048)
|
||||
name: StrictStr | None = Field(default=None, max_length=512)
|
||||
mime_type: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("mimeType", "mime_type"))
|
||||
revision: StrictInt = Field(..., ge=0)
|
||||
|
||||
@field_validator("session_id", "thread_id", "node_key", "source_key", "path", "name", "mime_type", mode="before")
|
||||
@classmethod
|
||||
def _trim_text(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return value or None
|
||||
return value
|
||||
|
||||
|
||||
class ArtifactSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id"))
|
||||
submitted: bool
|
||||
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
|
||||
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
|
||||
artifact: ArtifactReferenceInput
|
||||
|
||||
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
|
||||
@classmethod
|
||||
def _trim_optional_text(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return value or None
|
||||
return value
|
||||
|
||||
|
||||
class ArtifactReferenceResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
session_id: str | None = Field(default=None, serialization_alias="sessionId")
|
||||
thread_id: str | None = Field(default=None, serialization_alias="threadId")
|
||||
node_key: str | None = Field(default=None, serialization_alias="nodeKey")
|
||||
source_key: str = Field(serialization_alias="sourceKey")
|
||||
path: str
|
||||
name: str | None = None
|
||||
mime_type: str | None = Field(default=None, serialization_alias="mimeType")
|
||||
revision: int
|
||||
|
||||
|
||||
class ArtifactSubmissionResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: str
|
||||
task_id: str = Field(serialization_alias="taskId")
|
||||
submitted: bool
|
||||
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
|
||||
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
|
||||
task_delivery_id: str | None = Field(default=None, serialization_alias="taskDeliveryId")
|
||||
artifact: ArtifactReferenceResponse
|
||||
|
||||
|
||||
class ArtifactSubmissionListResponse(BaseModel):
|
||||
items: list[ArtifactSubmissionResponse]
|
||||
|
||||
|
||||
def _get_store(request: Request) -> RoundtableArtifactSubmissionStore:
|
||||
store = getattr(request.app.state, "roundtable_artifact_submission_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable artifact submission store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _task_id_or_422(value: str) -> str:
|
||||
task_id = value.strip()
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=422, detail="task id is required")
|
||||
if len(task_id) > 128:
|
||||
raise HTTPException(status_code=422, detail="task id must be at most 128 characters")
|
||||
return task_id
|
||||
|
||||
|
||||
def _artifact_payload(artifact: ArtifactReferenceInput) -> dict[str, Any]:
|
||||
source_key = artifact.source_key
|
||||
path = artifact.path
|
||||
if not source_key or not path:
|
||||
raise HTTPException(status_code=422, detail="artifact.sourceKey and artifact.path are required")
|
||||
name = artifact.name or PurePosixPath(path).name or path
|
||||
return {
|
||||
"session_id": artifact.session_id,
|
||||
"thread_id": artifact.thread_id,
|
||||
"node_key": artifact.node_key,
|
||||
"source_key": source_key,
|
||||
"path": path,
|
||||
"name": name,
|
||||
"mime_type": artifact.mime_type,
|
||||
"revision": artifact.revision,
|
||||
}
|
||||
|
||||
|
||||
def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
|
||||
return ArtifactSubmissionResponse(
|
||||
id=str(row["id"]),
|
||||
task_id=str(row["task_id"]),
|
||||
submitted=bool(row["submitted"]),
|
||||
submitted_at=row.get("submitted_at"),
|
||||
cancelled_at=row.get("cancelled_at"),
|
||||
task_delivery_id=row.get("task_delivery_id"),
|
||||
artifact=ArtifactReferenceResponse(
|
||||
session_id=row.get("session_id"),
|
||||
thread_id=row.get("thread_id"),
|
||||
node_key=row.get("node_key"),
|
||||
source_key=str(row["source_key"]),
|
||||
path=str(row["path"]),
|
||||
name=row.get("name"),
|
||||
mime_type=row.get("mime_type"),
|
||||
revision=int(row["revision"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}/artifacts", response_model=ArtifactSubmissionListResponse)
|
||||
async def list_artifact_submissions(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
submitted: bool | None = Query(default=True),
|
||||
) -> ArtifactSubmissionListResponse:
|
||||
"""List task artifacts by submission state (defaults to submitted only)."""
|
||||
rows = await _get_store(request).list_by_task(_task_id_or_422(task_id), submitted=submitted)
|
||||
return ArtifactSubmissionListResponse(items=[_response(row) for row in rows])
|
||||
|
||||
|
||||
@router.put("/{task_id}/artifacts/submission", response_model=ArtifactSubmissionResponse)
|
||||
async def set_artifact_submission(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
body: ArtifactSubmissionRequest,
|
||||
) -> ArtifactSubmissionResponse:
|
||||
"""Create/update a submission or retain an audit row while cancelling it."""
|
||||
path_task_id = _task_id_or_422(task_id)
|
||||
if body.task_id is not None and body.task_id != path_task_id:
|
||||
raise HTTPException(status_code=422, detail="taskId must match the path task id")
|
||||
|
||||
artifact = _artifact_payload(body.artifact)
|
||||
actor_id = await get_current_user(request)
|
||||
store = _get_store(request)
|
||||
if body.submitted:
|
||||
# The frontend previews submitted artifacts by threadId + path. Without
|
||||
# a thread reference a saved state could never be previewed correctly.
|
||||
if not artifact["thread_id"]:
|
||||
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
|
||||
row = await store.submit(
|
||||
path_task_id,
|
||||
artifact,
|
||||
task_delivery_id=body.task_delivery_id,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
else:
|
||||
row = await store.cancel(
|
||||
path_task_id,
|
||||
artifact,
|
||||
submission_id=body.submission_id,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Artifact submission not found")
|
||||
return _response(row)
|
||||
@@ -58,6 +58,10 @@ class ChainSeatSchema(BaseModel):
|
||||
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
|
||||
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
|
||||
seat_skill_directive: bool = False
|
||||
# Per-seat human checkpoint. Step 2 pauses after this seat (or its DAG
|
||||
# stage) completes and reuses the existing resume/intervention controls.
|
||||
# It is stored in the existing seats JSON, so no database migration is needed.
|
||||
human_validation: bool = False
|
||||
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
|
||||
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
|
||||
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
|
||||
|
||||
@@ -1622,6 +1622,102 @@ async def delete_custom_skill(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
|
||||
|
||||
|
||||
def _remove_skill_state_config(skill_name: str) -> None:
|
||||
"""Remove a deleted skill's enabled/display override from extensions config."""
|
||||
extensions_config = get_extensions_config()
|
||||
if skill_name not in extensions_config.skills:
|
||||
return
|
||||
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
extensions_config.skills.pop(skill_name, None)
|
||||
config_data = _dump_extensions_config(extensions_config)
|
||||
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
dir=str(config_path.parent),
|
||||
) as tmp_file:
|
||||
json.dump(config_data, tmp_file, indent=2)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
os.replace(tmp_path, config_path)
|
||||
finally:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
reload_extensions_config()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/skills/{skill_name}",
|
||||
summary="Delete Built-in Skill",
|
||||
description=(
|
||||
"Administrators may permanently delete a built-in skill. The skill is "
|
||||
"removed from enabled configuration and detached from agents that used it."
|
||||
),
|
||||
)
|
||||
async def delete_builtin_skill(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
agent_store=Depends(get_agent_store),
|
||||
) -> dict[str, bool]:
|
||||
try:
|
||||
if not _is_admin_user(request):
|
||||
raise HTTPException(status_code=403, detail="Only administrators can delete built-in skills")
|
||||
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
if skill.category != SkillCategory.PUBLIC:
|
||||
raise HTTPException(status_code=400, detail="Use the custom skill deletion endpoint for a custom skill")
|
||||
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
public_root = (storage.get_skills_root_path() / SkillCategory.PUBLIC.value).resolve()
|
||||
skill_dir = skill.skill_file.parent.resolve()
|
||||
if skill_dir == public_root or public_root not in skill_dir.parents:
|
||||
raise ValueError(f"Refusing to delete built-in skill outside the public skills directory: {skill.name}")
|
||||
|
||||
_remove_skill_state_config(skill.name)
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
try:
|
||||
from deerflow.skills.usage import forget
|
||||
|
||||
forget(skill.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await skill_store.delete_admin(skill.name)
|
||||
try:
|
||||
await get_tag_store(request).unassign_all("skill", skill.name)
|
||||
except Exception: # noqa: BLE001 -- tag cleanup must not block deletion
|
||||
pass
|
||||
|
||||
await cascade_skill_unpublished_or_deleted(
|
||||
skill.name,
|
||||
actor_user_id=_current_user_id(request),
|
||||
operation="delete",
|
||||
skill_owner_user_id=None,
|
||||
agent_store=agent_store,
|
||||
)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
logger.info("Administrator deleted built-in skill %s", skill.name)
|
||||
return {"success": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete built-in skill %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete built-in skill: {str(e)}")
|
||||
|
||||
|
||||
class SkillPublishRequest(BaseModel):
|
||||
"""Request body for toggling a custom skill's published flag."""
|
||||
|
||||
@@ -2080,11 +2176,16 @@ def _caller_can_edit_skill_files(
|
||||
record: dict[str, Any] | None,
|
||||
request: Request,
|
||||
) -> bool:
|
||||
"""Editing is limited to custom skills, and to either owner or admin."""
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
return False
|
||||
"""Whether the caller can edit or delete a file within *skill*.
|
||||
|
||||
Built-in skills are deployment-owned, so only administrators may change
|
||||
their files. Custom skills remain editable by their owner or by an
|
||||
administrator.
|
||||
"""
|
||||
if _is_admin_user(request):
|
||||
return True
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
return False
|
||||
if record is None:
|
||||
return False
|
||||
return record.get("owner_user_id") == _current_user_id(request)
|
||||
@@ -2125,6 +2226,24 @@ def _is_text_file(path: Path) -> bool:
|
||||
return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
|
||||
|
||||
|
||||
def _write_skill_text_file_atomically(target: Path, content: str) -> None:
|
||||
"""Replace an already-validated skill file without exposing partial text."""
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
dir=str(target.parent),
|
||||
) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
os.replace(tmp_path, target)
|
||||
finally:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
|
||||
def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
|
||||
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
|
||||
|
||||
@@ -2302,9 +2421,9 @@ async def download_skill_file(
|
||||
response_model=SkillFileSaveResponse,
|
||||
summary="Write Skill File (Text)",
|
||||
description=(
|
||||
"Replace the contents of a text file under a custom skill. Only the "
|
||||
"skill's owner or an admin may write; built-in (public) skills are "
|
||||
"read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
|
||||
"Replace the contents of a text file under a skill. Administrators may "
|
||||
"write built-in and custom skills; custom-skill owners may write their "
|
||||
"own skills. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
|
||||
"refresh the system-prompt cache."
|
||||
),
|
||||
)
|
||||
@@ -2318,11 +2437,9 @@ async def save_skill_file(
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
raise HTTPException(status_code=403, detail="Built-in skills are read-only")
|
||||
record = await skill_store.get_any(skill.name)
|
||||
if not _caller_can_edit_skill_files(skill, record, request):
|
||||
raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill")
|
||||
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can edit files in this skill")
|
||||
|
||||
target = _resolve_skill_relative_path(skill, body.path)
|
||||
if not target.exists():
|
||||
@@ -2338,7 +2455,7 @@ async def save_skill_file(
|
||||
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
|
||||
is_skill_md = relative_posix == SKILL_MD_FILE
|
||||
is_skill_md = relative_posix.casefold() == SKILL_MD_FILE.casefold()
|
||||
prev_content: str | None = None
|
||||
if target.exists():
|
||||
try:
|
||||
@@ -2350,20 +2467,21 @@ async def save_skill_file(
|
||||
# SKILL.md edits must keep their frontmatter valid (name must match).
|
||||
storage.validate_skill_markdown_content(skill.name, body.content)
|
||||
|
||||
storage.write_custom_skill(skill.name, relative_posix, body.content)
|
||||
_write_skill_text_file_atomically(target, body.content)
|
||||
|
||||
try:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_edit_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
"new_content": body.content,
|
||||
},
|
||||
)
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_edit_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
"new_content": body.content,
|
||||
},
|
||||
)
|
||||
except Exception as history_err: # noqa: BLE001 — history is best-effort
|
||||
logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
|
||||
|
||||
@@ -2393,6 +2511,75 @@ async def save_skill_file(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/skills/{skill_name}/files/content",
|
||||
status_code=204,
|
||||
summary="Delete Skill File",
|
||||
description=(
|
||||
"Delete one non-SKILL.md file under a skill. Administrators may delete "
|
||||
"files from built-in and custom skills; custom-skill owners may delete "
|
||||
"files from their own skills. The root ``SKILL.md`` manifest cannot be "
|
||||
"deleted because it defines the skill."
|
||||
),
|
||||
)
|
||||
async def delete_skill_file(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
path: str = Query(..., description="File path relative to the skill directory"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> None:
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
record = await skill_store.get_any(skill.name)
|
||||
if not _caller_can_edit_skill_files(skill, record, request):
|
||||
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can delete files in this skill")
|
||||
|
||||
target = _resolve_skill_relative_path(skill, path)
|
||||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
|
||||
|
||||
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
|
||||
if relative_posix.casefold() == SKILL_MD_FILE.casefold():
|
||||
raise HTTPException(status_code=400, detail="SKILL.md defines the skill and cannot be deleted")
|
||||
|
||||
prev_content: str | None = None
|
||||
if _is_text_file(target):
|
||||
try:
|
||||
prev_content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
target.unlink()
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
try:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_delete_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
},
|
||||
)
|
||||
except Exception as history_err: # noqa: BLE001
|
||||
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
|
||||
else:
|
||||
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete skill file %s/%s: %s", skill_name, path, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete skill file: {e}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
|
||||
@@ -33,32 +33,64 @@ concurrency:
|
||||
fail_open: true
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: $DEEPSEEK_CHAT_API_KEY
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: $GLM5_FP8_API_KEY
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: $DEEPSEEK_CHAT_API_KEY
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: $GLM5_FP8_API_KEY
|
||||
base_url: https://api.us-west-2.modal.direct/v2
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
@@ -71,7 +103,6 @@ models:
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
@@ -136,52 +167,9 @@ models:
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
|
||||
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
|
||||
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
display_name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# 识图(图片识别)配置。
|
||||
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
|
||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""Index optional task-scoped position-roundtable history.
|
||||
|
||||
Revision ID: 20260808_01
|
||||
Revises: 20260729_01
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260808_01"
|
||||
down_revision = "20260729_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = "position_roundtable_sessions"
|
||||
_INDEX = "ix_position_roundtable_sessions_task_updated"
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return any(
|
||||
column["name"] == column_name
|
||||
for column in inspect(op.get_bind()).get_columns(table_name)
|
||||
)
|
||||
|
||||
|
||||
def _has_index(table_name: str, index_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
index["name"] for index in inspect(op.get_bind()).get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table(_TABLE):
|
||||
return
|
||||
# Historical records must stay in the original personal mode. New records
|
||||
# set this flag only when a route taskId explicitly requests sharing.
|
||||
if not _has_column(_TABLE, "task_scoped"):
|
||||
op.add_column(
|
||||
_TABLE,
|
||||
sa.Column(
|
||||
"task_scoped",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
if not _has_index(_TABLE, _INDEX):
|
||||
op.create_index(_INDEX, _TABLE, ["external_task_id", "updated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _has_table(_TABLE):
|
||||
return
|
||||
if _has_index(_TABLE, _INDEX):
|
||||
op.drop_index(_INDEX, table_name=_TABLE)
|
||||
if _has_column(_TABLE, "task_scoped"):
|
||||
op.drop_column(_TABLE, "task_scoped")
|
||||
+2
@@ -57,6 +57,7 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
|
||||
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
|
||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||
from deerflow.persistence.roundtable_drafts.model import (
|
||||
@@ -121,6 +122,7 @@ __all__ = [
|
||||
"RecommendedQuestionRow",
|
||||
"ResearchTemplateRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableArtifactSubmissionRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableDraftShareRow",
|
||||
"RoundtableDiagnosticRow",
|
||||
|
||||
+14
-3
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -21,12 +21,23 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
class PositionRoundtableSessionRow(Base):
|
||||
__tablename__ = "position_roundtable_sessions"
|
||||
__table_args__ = (
|
||||
# History is read as "all conversations for one task, newest first".
|
||||
# `user_id` remains audit data only; it must not partition a shared
|
||||
# task workspace.
|
||||
Index(
|
||||
"ix_position_roundtable_sessions_task_updated",
|
||||
"external_task_id",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Phase one uses normal account isolation. A future membership / role ACL
|
||||
# can be added without changing the session or thread identifiers.
|
||||
# Creator audit field. `task_scoped`, not merely external_task_id, opts a
|
||||
# session into sharing. This keeps historical sidebar/mock rows private.
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
|
||||
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
|
||||
+41
-32
@@ -46,11 +46,17 @@ class PositionRoundtableRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
|
||||
"""Task-bound sessions are shared; unbound legacy sessions stay private."""
|
||||
return bool(row.task_scoped) or row.user_id == user_id
|
||||
|
||||
@staticmethod
|
||||
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"external_task_id": row.external_task_id,
|
||||
"task_scoped": bool(row.task_scoped),
|
||||
"task_snapshot": _loads(row.task_snapshot) or {},
|
||||
"chain_id": row.chain_id,
|
||||
"chain_snapshot": _loads(row.chain_snapshot),
|
||||
@@ -98,12 +104,14 @@ class PositionRoundtableRepository:
|
||||
*,
|
||||
external_task_id: str | None,
|
||||
task_snapshot: dict[str, Any] | None,
|
||||
task_scoped: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = PositionRoundtableSessionRow(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
external_task_id=external_task_id or None,
|
||||
task_scoped=bool(external_task_id) if task_scoped is None else task_scoped,
|
||||
task_snapshot=_dumps(task_snapshot or {}),
|
||||
status="intent_pending",
|
||||
created_at=now,
|
||||
@@ -165,7 +173,8 @@ class PositionRoundtableRepository:
|
||||
row = PositionRoundtableSessionRow(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
external_task_id=session_payload.get("external_task_id") or None,
|
||||
external_task_id=str(session_payload.get("external_task_id") or "").strip() or None,
|
||||
task_scoped=False,
|
||||
task_snapshot=_dumps(session_payload.get("task_snapshot") or {}),
|
||||
chain_id=session_payload.get("chain_id") or None,
|
||||
chain_snapshot=_dumps(session_payload.get("chain_snapshot")),
|
||||
@@ -246,9 +255,17 @@ class PositionRoundtableRepository:
|
||||
async def list_sessions(
|
||||
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
|
||||
"""List shared task history or the caller's legacy personal history."""
|
||||
if external_task_id:
|
||||
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
|
||||
stmt = select(PositionRoundtableSessionRow).where(
|
||||
PositionRoundtableSessionRow.external_task_id == external_task_id,
|
||||
PositionRoundtableSessionRow.task_scoped.is_(True),
|
||||
)
|
||||
else:
|
||||
stmt = select(PositionRoundtableSessionRow).where(
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
PositionRoundtableSessionRow.task_scoped.is_(False),
|
||||
)
|
||||
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
@@ -257,7 +274,7 @@ class PositionRoundtableRepository:
|
||||
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
if row is None or not self._can_access(row, user_id):
|
||||
return None
|
||||
return self._session_dict(row)
|
||||
|
||||
@@ -280,7 +297,7 @@ class PositionRoundtableRepository:
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
if row is None or not self._can_access(row, user_id):
|
||||
return None
|
||||
if task_snapshot is not _UNSET:
|
||||
row.task_snapshot = _dumps(task_snapshot)
|
||||
@@ -326,7 +343,7 @@ class PositionRoundtableRepository:
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
if row is None or not self._can_access(row, user_id):
|
||||
return None
|
||||
node_rows = (
|
||||
await session.execute(
|
||||
@@ -371,7 +388,7 @@ class PositionRoundtableRepository:
|
||||
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
async with self._sf() as session:
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or parent.user_id != user_id:
|
||||
if parent is None or not self._can_access(parent, user_id):
|
||||
return None
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -384,16 +401,13 @@ class PositionRoundtableRepository:
|
||||
|
||||
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or not self._can_access(parent, user_id):
|
||||
return None
|
||||
stmt = select(PositionRoundtableNodeRow).where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
return self._node_dict(row) if row is not None else None
|
||||
|
||||
@@ -415,16 +429,13 @@ class PositionRoundtableRepository:
|
||||
increment_rejection: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or not self._can_access(parent, user_id):
|
||||
return None
|
||||
stmt = select(PositionRoundtableNodeRow).where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
@@ -472,7 +483,7 @@ class PositionRoundtableRepository:
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or parent.user_id != user_id:
|
||||
if parent is None or not self._can_access(parent, user_id):
|
||||
return None
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -538,19 +549,18 @@ class PositionRoundtableRepository:
|
||||
return await self.update_session(session_id, user_id, status="archived")
|
||||
|
||||
async def delete_session(self, session_id: str, user_id: str) -> bool:
|
||||
"""Delete one user-owned history record and all of its node snapshots."""
|
||||
"""Delete a task-shared row or the caller's personal legacy row."""
|
||||
async with self._sf() as session:
|
||||
parent = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableSessionRow)
|
||||
.where(
|
||||
PositionRoundtableSessionRow.id == session_id,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if parent is None:
|
||||
if parent is None or not self._can_access(parent, user_id):
|
||||
return False
|
||||
|
||||
# Do not rely on database-specific FK cascade settings. SQLite
|
||||
@@ -564,7 +574,6 @@ class PositionRoundtableRepository:
|
||||
await session.execute(
|
||||
delete(PositionRoundtableSessionRow).where(
|
||||
PositionRoundtableSessionRow.id == session_id,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Task-scoped roundtable artifact submission status persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
|
||||
from deerflow.persistence.roundtable_artifact_submissions.memory import MemoryRoundtableArtifactSubmissionStore
|
||||
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
|
||||
from deerflow.persistence.roundtable_artifact_submissions.sql import RoundtableArtifactSubmissionRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryRoundtableArtifactSubmissionStore",
|
||||
"RoundtableArtifactSubmissionRepository",
|
||||
"RoundtableArtifactSubmissionRow",
|
||||
"RoundtableArtifactSubmissionStore",
|
||||
"make_roundtable_artifact_submission_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_artifact_submission_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryRoundtableArtifactSubmissionStore()
|
||||
return RoundtableArtifactSubmissionRepository(session_factory)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""Persistence contract for task-scoped roundtable artifact submissions.
|
||||
|
||||
The store deliberately keeps only a stable reference to an artifact in a
|
||||
DeerFlow thread sandbox. It does not copy the artifact body and never calls a
|
||||
downstream task-delivery service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RoundtableArtifactSubmissionStore(ABC):
|
||||
"""Shared submission-status store, partitioned only by task id."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
|
||||
"""Return artifact submission records for one task, newest first."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def submit(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
task_delivery_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or update the idempotent submission record for an artifact."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def cancel(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
submission_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Mark a submission as cancelled without deleting its audit record."""
|
||||
raise NotImplementedError
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
"""In-memory roundtable artifact-submission store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
|
||||
from deerflow.persistence.types import BEIJING_TZ
|
||||
|
||||
|
||||
def submission_key(task_id: str, artifact: dict[str, Any]) -> str:
|
||||
"""Return the canonical server-owned idempotency key for an artifact."""
|
||||
source_key = str(artifact["source_key"])
|
||||
path = str(artifact["path"])
|
||||
revision = int(artifact["revision"])
|
||||
raw = "\x1f".join((task_id, source_key, path, str(revision)))
|
||||
return sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).astimezone(BEIJING_TZ).isoformat()
|
||||
|
||||
|
||||
def _artifact_values(artifact: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": artifact.get("session_id"),
|
||||
"thread_id": artifact.get("thread_id"),
|
||||
"node_key": artifact.get("node_key"),
|
||||
"source_key": artifact["source_key"],
|
||||
"path": artifact["path"],
|
||||
"name": artifact.get("name"),
|
||||
"mime_type": artifact.get("mime_type"),
|
||||
"revision": int(artifact["revision"]),
|
||||
}
|
||||
|
||||
|
||||
class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
|
||||
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
|
||||
rows = [row for row in self._rows.values() if row["task_id"] == task_id and (submitted is None or row["submitted"] is submitted)]
|
||||
rows.sort(key=lambda row: row["updated_at"], reverse=True)
|
||||
return deepcopy(rows)
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
task_delivery_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
key = submission_key(task_id, artifact)
|
||||
row = self._rows.get((task_id, key))
|
||||
values = _artifact_values(artifact)
|
||||
if row is None:
|
||||
now = _now()
|
||||
row = {
|
||||
"id": uuid4().hex,
|
||||
"task_id": task_id,
|
||||
"idempotency_key": key,
|
||||
**values,
|
||||
"task_delivery_id": task_delivery_id,
|
||||
"submitted": True,
|
||||
"submitted_at": now,
|
||||
"cancelled_at": None,
|
||||
"created_by": actor_id,
|
||||
"updated_by": actor_id,
|
||||
"submitted_by": actor_id,
|
||||
"cancelled_by": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._rows[(task_id, key)] = row
|
||||
return deepcopy(row)
|
||||
|
||||
changed = (
|
||||
not row["submitted"]
|
||||
or row.get("task_delivery_id") != task_delivery_id
|
||||
or any(row.get(name) != value for name, value in values.items())
|
||||
)
|
||||
if changed:
|
||||
now = _now()
|
||||
row.update(values)
|
||||
row.update(
|
||||
{
|
||||
"task_delivery_id": task_delivery_id,
|
||||
"submitted": True,
|
||||
"submitted_at": now,
|
||||
"cancelled_at": None,
|
||||
"updated_by": actor_id,
|
||||
"submitted_by": actor_id,
|
||||
"cancelled_by": None,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return deepcopy(row)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
submission_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
key = submission_key(task_id, artifact)
|
||||
row = self._rows.get((task_id, key))
|
||||
if row is None or (submission_id is not None and row["id"] != submission_id):
|
||||
return None
|
||||
if row["submitted"]:
|
||||
now = _now()
|
||||
row.update(
|
||||
{
|
||||
"submitted": False,
|
||||
"cancelled_at": now,
|
||||
"updated_by": actor_id,
|
||||
"cancelled_by": actor_id,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return deepcopy(row)
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""ORM model for task-scoped roundtable artifact submission status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class RoundtableArtifactSubmissionRow(Base):
|
||||
"""One auditable submission state for one task artifact revision.
|
||||
|
||||
The natural idempotency key is a SHA-256 digest of
|
||||
``task_id + source_key + artifact_path + revision``. Keeping the digest
|
||||
short avoids a MySQL unique-index limit while the component columns remain
|
||||
available for diagnostics and response shaping.
|
||||
"""
|
||||
|
||||
__tablename__ = "roundtable_artifact_submissions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "idempotency_key", name="uq_roundtable_artifact_submission_task_key"),
|
||||
Index("ix_roundtable_artifact_submission_task_state_updated", "task_id", "submitted", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
artifact_path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
node_key: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
artifact_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
task_delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
submitted_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
cancelled_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""SQLAlchemy-backed task-scoped roundtable artifact-submission store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
|
||||
from deerflow.persistence.roundtable_artifact_submissions.memory import submission_key
|
||||
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
|
||||
|
||||
|
||||
def _timestamp(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if isinstance(value, datetime) else value
|
||||
|
||||
|
||||
def _to_dict(row: RoundtableArtifactSubmissionRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"idempotency_key": row.idempotency_key,
|
||||
"session_id": row.session_id,
|
||||
"thread_id": row.thread_id,
|
||||
"node_key": row.node_key,
|
||||
"source_key": row.source_key,
|
||||
"path": row.artifact_path,
|
||||
"name": row.artifact_name,
|
||||
"mime_type": row.mime_type,
|
||||
"revision": row.revision,
|
||||
"task_delivery_id": row.task_delivery_id,
|
||||
"submitted": row.submitted,
|
||||
"submitted_at": _timestamp(row.submitted_at),
|
||||
"cancelled_at": _timestamp(row.cancelled_at),
|
||||
"created_by": row.created_by,
|
||||
"updated_by": row.updated_by,
|
||||
"submitted_by": row.submitted_by,
|
||||
"cancelled_by": row.cancelled_by,
|
||||
"created_at": _timestamp(row.created_at),
|
||||
"updated_at": _timestamp(row.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _apply_artifact(row: RoundtableArtifactSubmissionRow, artifact: dict[str, Any]) -> None:
|
||||
row.session_id = artifact.get("session_id")
|
||||
row.thread_id = artifact.get("thread_id")
|
||||
row.node_key = artifact.get("node_key")
|
||||
row.source_key = artifact["source_key"]
|
||||
row.artifact_path = artifact["path"]
|
||||
row.artifact_name = artifact.get("name")
|
||||
row.mime_type = artifact.get("mime_type")
|
||||
row.revision = int(artifact["revision"])
|
||||
|
||||
|
||||
def _requires_submission_update(
|
||||
row: RoundtableArtifactSubmissionRow,
|
||||
artifact: dict[str, Any],
|
||||
task_delivery_id: str | None,
|
||||
) -> bool:
|
||||
return (
|
||||
not row.submitted
|
||||
or row.task_delivery_id != task_delivery_id
|
||||
or row.session_id != artifact.get("session_id")
|
||||
or row.thread_id != artifact.get("thread_id")
|
||||
or row.node_key != artifact.get("node_key")
|
||||
or row.artifact_name != artifact.get("name")
|
||||
or row.mime_type != artifact.get("mime_type")
|
||||
)
|
||||
|
||||
|
||||
class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
|
||||
stmt = select(RoundtableArtifactSubmissionRow).where(RoundtableArtifactSubmissionRow.task_id == task_id)
|
||||
if submitted is not None:
|
||||
stmt = stmt.where(RoundtableArtifactSubmissionRow.submitted.is_(submitted))
|
||||
stmt = stmt.order_by(RoundtableArtifactSubmissionRow.updated_at.desc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
task_delivery_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
key = submission_key(task_id, artifact)
|
||||
stmt = select(RoundtableArtifactSubmissionRow).where(
|
||||
RoundtableArtifactSubmissionRow.task_id == task_id,
|
||||
RoundtableArtifactSubmissionRow.idempotency_key == key,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
now = datetime.now(UTC)
|
||||
created = row is None
|
||||
if row is None:
|
||||
row = RoundtableArtifactSubmissionRow(
|
||||
id=uuid4().hex,
|
||||
task_id=task_id,
|
||||
idempotency_key=key,
|
||||
task_delivery_id=task_delivery_id,
|
||||
submitted=True,
|
||||
submitted_at=now,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
submitted_by=actor_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
_apply_artifact(row, artifact)
|
||||
session.add(row)
|
||||
elif _requires_submission_update(row, artifact, task_delivery_id):
|
||||
_apply_artifact(row, artifact)
|
||||
row.task_delivery_id = task_delivery_id
|
||||
row.submitted = True
|
||||
row.submitted_at = now
|
||||
row.cancelled_at = None
|
||||
row.updated_by = actor_id
|
||||
row.submitted_by = actor_id
|
||||
row.cancelled_by = None
|
||||
row.updated_at = now
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
# Two callers can both observe a missing row. The unique key
|
||||
# turns that race into a normal idempotent retry instead of a
|
||||
# 500. If their non-key metadata differs, apply this request
|
||||
# after loading the winner so PUT still has upsert semantics.
|
||||
await session.rollback()
|
||||
if not created:
|
||||
raise
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
if row is None:
|
||||
raise
|
||||
if _requires_submission_update(row, artifact, task_delivery_id):
|
||||
now = datetime.now(UTC)
|
||||
_apply_artifact(row, artifact)
|
||||
row.task_delivery_id = task_delivery_id
|
||||
row.submitted = True
|
||||
row.submitted_at = now
|
||||
row.cancelled_at = None
|
||||
row.updated_by = actor_id
|
||||
row.submitted_by = actor_id
|
||||
row.cancelled_by = None
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
task_id: str,
|
||||
artifact: dict[str, Any],
|
||||
*,
|
||||
submission_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
key = submission_key(task_id, artifact)
|
||||
stmt = select(RoundtableArtifactSubmissionRow).where(
|
||||
RoundtableArtifactSubmissionRow.task_id == task_id,
|
||||
RoundtableArtifactSubmissionRow.idempotency_key == key,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
if row is None or (submission_id is not None and row.id != submission_id):
|
||||
return None
|
||||
if row.submitted:
|
||||
now = datetime.now(UTC)
|
||||
row.submitted = False
|
||||
row.cancelled_at = now
|
||||
row.updated_by = actor_id
|
||||
row.cancelled_by = actor_id
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
@@ -8,7 +8,7 @@ SKILL_MD_FILE = "SKILL.md"
|
||||
class SkillCategory(StrEnum):
|
||||
"""Source category for a skill.
|
||||
|
||||
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
|
||||
- ``PUBLIC``: built-in skill bundled with the platform; administrator-managed.
|
||||
- ``CUSTOM``: user-authored skill that can be edited or deleted.
|
||||
"""
|
||||
|
||||
|
||||
@@ -117,6 +117,11 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA
|
||||
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
|
||||
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- position_roundtable_sessions: opt-in task sharing, migration 20260808_01 ----
|
||||
-- Existing records remain personal (`task_scoped = 0`). New records become
|
||||
-- shared only when the caller explicitly sends a non-empty route taskId.
|
||||
CALL _add_column_if_missing('position_roundtable_sessions', 'task_scoped', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
|
||||
-- ---- 按钮管理「外部链接」追加登录参数,对应 migration 20260625_01 ----------------
|
||||
-- login_params_json: url 外链追加的登录参数列表 JSON([{key,source,custom_value},...]),
|
||||
-- 由前端运行时从 userInfo 解析后拼到地址尾部;NULL/空 = 不追加(旧行为)。
|
||||
@@ -199,5 +204,6 @@ SELECT table_name, column_name
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
|
||||
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -68,6 +68,10 @@ CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_run_creat
|
||||
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_created_user', '`created_at`, `user_id`');
|
||||
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_user_created', '`user_id`, `created_at`');
|
||||
|
||||
-- ---- position_roundtable_sessions --------------------------------------------
|
||||
-- Explicit taskId history is loaded by taskId and newest update time.
|
||||
CALL _create_index_if_missing('position_roundtable_sessions', 'ix_position_roundtable_sessions_task_updated', '`external_task_id`, `updated_at`');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _create_index_if_missing;
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Focused regression tests for administrator model configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from starlette.routing import Match
|
||||
|
||||
from app.gateway.routers import model_management as router
|
||||
|
||||
|
||||
def test_replace_models_block_keeps_other_sections_and_comments() -> None:
|
||||
raw = """config_version: 1
|
||||
models:
|
||||
# Existing provider note
|
||||
- name: old-model
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: old-model
|
||||
# Disabled provider example remains useful to operators.
|
||||
# - name: disabled-model
|
||||
# model: disabled-model
|
||||
agents:
|
||||
default: lead_agent
|
||||
"""
|
||||
|
||||
replaced = router._replace_models_block(
|
||||
raw,
|
||||
[
|
||||
{
|
||||
"name": "new-model",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "new-model",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(replaced)
|
||||
assert parsed["models"] == [
|
||||
{
|
||||
"name": "new-model",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "new-model",
|
||||
}
|
||||
]
|
||||
assert parsed["agents"] == {"default": "lead_agent"}
|
||||
assert "# Existing provider note" in replaced
|
||||
assert "# Disabled provider example remains useful to operators." in replaced
|
||||
|
||||
|
||||
def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
|
||||
message = "provider rejected raw-secret and resolved-secret"
|
||||
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
|
||||
|
||||
|
||||
def test_model_mutation_routes_accept_names_with_slashes() -> None:
|
||||
for method in ("PUT", "DELETE"):
|
||||
route = next(
|
||||
item
|
||||
for item in router.router.routes
|
||||
if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}"
|
||||
)
|
||||
match, child_scope = route.matches(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/api/admin/models/zai-org/GLM-5-FP8",
|
||||
}
|
||||
)
|
||||
assert match is Match.FULL
|
||||
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"""models:
|
||||
- name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: GLM-5-FP8
|
||||
- name: keep-me
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: keep-me
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
saved["raw_before"] = raw_before
|
||||
saved["models"] = models
|
||||
return models
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "_save_models", save_models)
|
||||
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
||||
|
||||
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
|
||||
|
||||
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
|
||||
assert saved["models"] == [
|
||||
{
|
||||
"name": "keep-me",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "keep-me",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
original = "models: []\nagent: lead_agent\n"
|
||||
config_path.write_text(original, encoding="utf-8")
|
||||
messages_seen = []
|
||||
|
||||
class FakeChatModel:
|
||||
async def ainvoke(self, messages):
|
||||
messages_seen.extend(messages)
|
||||
return SimpleNamespace(content="OK")
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
|
||||
request = SimpleNamespace(
|
||||
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
|
||||
app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
|
||||
)
|
||||
body = router.ModelTestRequest(
|
||||
model=router.ModelUpsertRequest(
|
||||
name="test-model",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
model="test-model",
|
||||
api_key="raw-secret",
|
||||
)
|
||||
)
|
||||
|
||||
result = await router.test_model_connection(body, request)
|
||||
|
||||
assert result.success is True
|
||||
assert result.preview == "OK"
|
||||
assert len(messages_seen) == 1
|
||||
assert messages_seen[0].content == "Reply with exactly: OK"
|
||||
assert config_path.read_text(encoding="utf-8") == original
|
||||
@@ -3,6 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -14,9 +17,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.gateway import deps as gateway_deps
|
||||
from app.gateway import services as gateway_services
|
||||
from app.gateway.app import create_app
|
||||
from app.gateway.routers import _roundtable_seed as roundtable_seed
|
||||
from app.gateway.routers import intent as intent_router
|
||||
from app.gateway.routers import multi_agent
|
||||
from app.gateway.routers import position_roundtable as position_roundtable_router
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
required_agent_ids,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||
@@ -55,6 +63,248 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
|
||||
assert position_roundtable_router.router.prefix == "/position-roundtable"
|
||||
|
||||
|
||||
def test_position_intent_agent_is_a_seeded_functional_agent():
|
||||
"""Fresh deployments must materialize the dedicated position intent agent."""
|
||||
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
|
||||
|
||||
|
||||
def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract():
|
||||
"""New deployments must not fall back to the retired subtask JSON contract."""
|
||||
action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID
|
||||
soul = (action_assets / "SOUL.md").read_text(encoding="utf-8")
|
||||
config = (action_assets / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
assert "position-roundtable-action-create" in config
|
||||
for field in (
|
||||
"actionName",
|
||||
"actionDescription",
|
||||
"actionType",
|
||||
"publishChannel",
|
||||
"infoFormat",
|
||||
"responsiblePosition",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"taskId",
|
||||
):
|
||||
assert field in soul
|
||||
assert "/task/action/add" in soul
|
||||
assert "批量新增" in soul
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values():
|
||||
"""The action planner must see exact dictValue choices, not frontend labels only."""
|
||||
dictionaries = position_roundtable_router.ActionPlanDictionaries(
|
||||
publish_channels=[{"label": "微信公众号", "value": "11"}],
|
||||
action_types=[{"label": "宣传", "value": "21"}],
|
||||
info_formats=[{"label": "图文", "value": "31"}],
|
||||
responsible_positions=[{"label": "规划岗", "value": "41"}],
|
||||
)
|
||||
prompt = await position_roundtable_router._build_builtin_prompt(
|
||||
kind="action_plan",
|
||||
session={
|
||||
"task_snapshot": {"title": "任务 A", "description": "生成行动"},
|
||||
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
|
||||
},
|
||||
nodes=[],
|
||||
action_task_id=1001,
|
||||
action_dictionaries=dictionaries,
|
||||
)
|
||||
|
||||
assert "当前 taskId:1001" in prompt
|
||||
assert "微信公众号 → 11" in prompt
|
||||
assert "宣传 → 21" in prompt
|
||||
assert "图文 → 31" in prompt
|
||||
assert "规划岗 → 41" in prompt
|
||||
assert "id、title、goal" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable():
|
||||
"""A 404 from the task-system dictionaries must not block local planning."""
|
||||
prompt = await position_roundtable_router._build_builtin_prompt(
|
||||
kind="action_plan",
|
||||
session={
|
||||
"task_snapshot": {"title": "本地演示任务"},
|
||||
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
|
||||
},
|
||||
nodes=[],
|
||||
action_task_id="abc123",
|
||||
action_dictionaries=position_roundtable_router.ActionPlanDictionaries(),
|
||||
)
|
||||
|
||||
assert "字典未获取完整" in prompt
|
||||
assert "不得调用 Skill" in prompt
|
||||
assert "0 占位" in prompt
|
||||
|
||||
|
||||
def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion():
|
||||
"""A local demo id must not stop the action-plan conversation at prepare time."""
|
||||
body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123")
|
||||
|
||||
assert body.task_id == "abc123"
|
||||
|
||||
|
||||
def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path):
|
||||
"""The batch Skill must reject old task-subdivision fields before any HTTP call."""
|
||||
archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill"
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
archive.extractall(tmp_path / "skill-package")
|
||||
script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py"
|
||||
spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
valid = [{
|
||||
"actionName": "发布内容",
|
||||
"actionDescription": "向目标渠道发布审核通过的信息。",
|
||||
"actionType": "1",
|
||||
"publishChannel": "2",
|
||||
"infoFormat": "3",
|
||||
"responsiblePosition": "4",
|
||||
"startTime": "2026-08-12T09:00:00+08:00",
|
||||
"endTime": "2026-08-12T18:00:00+08:00",
|
||||
"taskId": 1001,
|
||||
}]
|
||||
source = tmp_path / "action-plan-subtasks.json"
|
||||
source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8")
|
||||
assert module.read_actions(source, 1001) == [{
|
||||
**valid[0],
|
||||
"actionType": 1,
|
||||
"publishChannel": 2,
|
||||
"infoFormat": 3,
|
||||
"responsiblePosition": 4,
|
||||
}]
|
||||
|
||||
source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8")
|
||||
with pytest.raises(module.ActionInputError, match="多余字段"):
|
||||
module.read_actions(source, 1001)
|
||||
|
||||
|
||||
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
|
||||
"""Dedicated schema must not regress into solutioning or gatekeeping."""
|
||||
prompt = (
|
||||
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
for phrase in (
|
||||
"最多 5 轮用户协助",
|
||||
"除 ask_clarification 外,禁止调用任何工具、技能或操作",
|
||||
"你都不拒绝、不评价、不设限",
|
||||
"不得说“不能帮你”“不在职责范围内”“请换一个问题”",
|
||||
"coreGoals / riskWarnings / keyPoints / strategicSignificance",
|
||||
"不能把“主题已识别”误判为“意图已明确”",
|
||||
"任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助",
|
||||
"必须分析任务级风险",
|
||||
"同一轮可发起多张用户协助卡",
|
||||
"不得把多个问题塞进一张卡",
|
||||
):
|
||||
assert phrase in prompt
|
||||
|
||||
|
||||
def test_position_intent_clarification_waits_for_the_user_before_ready():
|
||||
"""A same-run READY block must not consume the user-assistance pause."""
|
||||
assert intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
None,
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=False,
|
||||
)
|
||||
|
||||
|
||||
def test_position_intent_counts_multi_card_assistance_as_one_round():
|
||||
"""Five user-assistance rounds must not be consumed by five cards at once."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{"id": "scope", "name": "ask_clarification", "args": {}},
|
||||
{"id": "purpose", "name": "ask_clarification", "args": {}},
|
||||
{"id": "time", "name": "ask_clarification", "args": {}},
|
||||
],
|
||||
},
|
||||
{"type": "human", "content": "用户统一答复"},
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{"id": "risk", "name": "ask_clarification", "args": {}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert intent_router._count_past_clarifications(messages) == 4
|
||||
assert intent_router._count_past_clarification_rounds(messages) == 2
|
||||
assert intent_router._max_clarification_rounds(intent_router.INTENT_AGENT_ID) == 3
|
||||
assert (
|
||||
intent_router._max_clarification_rounds(intent_router.POSITION_INTENT_AGENT_ID)
|
||||
== 5
|
||||
)
|
||||
|
||||
|
||||
def test_position_intent_seed_repairs_its_contract_without_touching_other_agents(tmp_path, monkeypatch):
|
||||
"""The dedicated schema prompt must replace an accidentally seeded old version."""
|
||||
agents_dir = tmp_path / "agents"
|
||||
monkeypatch.setattr(
|
||||
roundtable_seed,
|
||||
"get_paths",
|
||||
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
|
||||
)
|
||||
|
||||
position_dir = agents_dir / POSITION_INTENT_AGENT_ID
|
||||
position_dir.mkdir(parents=True)
|
||||
(position_dir / "SOUL.md").write_text("outdated prompt", encoding="utf-8")
|
||||
assert roundtable_seed._ensure_one(POSITION_INTENT_AGENT_ID) is True
|
||||
expected_soul = (
|
||||
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert (position_dir / "SOUL.md").read_text(encoding="utf-8") == expected_soul
|
||||
|
||||
generic_dir = agents_dir / roundtable_seed.INTENT_AGENT_ID
|
||||
generic_dir.mkdir(parents=True)
|
||||
generic_soul = generic_dir / "SOUL.md"
|
||||
generic_soul.write_text("deployed generic tune", encoding="utf-8")
|
||||
assert roundtable_seed._ensure_one(roundtable_seed.INTENT_AGENT_ID) is False
|
||||
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
|
||||
|
||||
|
||||
def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch):
|
||||
"""A code upgrade may add the Skill without overwriting a user-tuned SOUL."""
|
||||
agents_dir = tmp_path / "agents"
|
||||
monkeypatch.setattr(
|
||||
roundtable_seed,
|
||||
"get_paths",
|
||||
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
|
||||
)
|
||||
action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID
|
||||
action_dir.mkdir(parents=True)
|
||||
(action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8")
|
||||
(action_dir / "config.yaml").write_text(
|
||||
"id: position-action-planner\nskills:\n - existing-skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True
|
||||
config = (action_dir / "config.yaml").read_text(encoding="utf-8")
|
||||
assert "existing-skill" in config
|
||||
assert "position-roundtable-action-create" in config
|
||||
assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
|
||||
"""Step 1 initialization must not depend on the deployed Gateway port."""
|
||||
@@ -91,6 +341,71 @@ async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_position_intent_init_selects_its_dedicated_agent(monkeypatch):
|
||||
"""Position roundtable must never mutate the generic Step-1 agent contract."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
|
||||
|
||||
async def fake_create_thread_direct(request, *, metadata):
|
||||
captured["metadata"] = metadata
|
||||
return "position-intent-thread"
|
||||
|
||||
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
|
||||
|
||||
result = await intent_router.init_intent(
|
||||
SimpleNamespace(),
|
||||
{
|
||||
"intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
"task_context": {"task": {"id": "task-position-001"}},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "success",
|
||||
"agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
"thread_id": "position-intent-thread",
|
||||
}
|
||||
assert captured["metadata"] == {
|
||||
**intent_router._ROUNDTABLE_THREAD_METADATA,
|
||||
"taskId": "task-position-001",
|
||||
"position_roundtable_task_id": "task-position-001",
|
||||
"position_task_id": "task-position-001:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
"position_roundtable_intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
}
|
||||
|
||||
|
||||
def test_position_intent_schema_is_required_for_new_snapshots_and_legacy_is_kept():
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{
|
||||
"coreGoals": ["形成统一处置方案"],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": ["需要在本周完成"],
|
||||
"strategicSignificance": ["为后续岗位协同提供统一边界"],
|
||||
}
|
||||
)
|
||||
assert not position_roundtable_router._has_confirmed_intent(
|
||||
{"coreGoals": ["形成统一处置方案"], "riskWarnings": []}
|
||||
)
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{"objective": "历史任务", "constraints": []}
|
||||
)
|
||||
answer = position_roundtable_router._intent_node_answer(
|
||||
{
|
||||
"coreGoals": ["形成统一处置方案"],
|
||||
"riskWarnings": ["关键数据尚待确认"],
|
||||
"keyPoints": ["聚焦当前任务范围"],
|
||||
"strategicSignificance": ["支撑业务链协同推进"],
|
||||
}
|
||||
)
|
||||
assert "核心目标" in answer
|
||||
assert "风险提示" in answer
|
||||
assert "关键要点" in answer
|
||||
assert "战略意义" in answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
created = await store.create_session(
|
||||
@@ -208,14 +523,91 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
|
||||
assert saved_node is not None
|
||||
assert saved_node["conversation_snapshot"] == node_conversation
|
||||
|
||||
# Ownership checks apply to delete as well.
|
||||
assert not await store.delete_session(created["id"], "user-b")
|
||||
assert await store.get_session(created["id"], "user-a") is not None
|
||||
assert await store.delete_session(created["id"], "user-a")
|
||||
# Position roundtable history is shared by taskId, so a collaborator can
|
||||
# read and remove the same task conversation without being its creator.
|
||||
assert await store.get_session(created["id"], "user-b") is not None
|
||||
assert await store.delete_session(created["id"], "user-b")
|
||||
assert await store.get_session(created["id"], "user-a") is None
|
||||
assert await store.list_nodes(created["id"], "user-a") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store):
|
||||
shared = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="external-task-100",
|
||||
task_snapshot={"id": "external-task-100", "title": "共享任务"},
|
||||
)
|
||||
await store.create_session(
|
||||
"user-b",
|
||||
external_task_id="external-task-200",
|
||||
task_snapshot={"id": "external-task-200", "title": "另一任务"},
|
||||
)
|
||||
|
||||
visible_to_collaborator = await store.list_sessions(
|
||||
"user-b", external_task_id="external-task-100"
|
||||
)
|
||||
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]]
|
||||
|
||||
updated = await store.update_session(
|
||||
shared["id"],
|
||||
"user-b",
|
||||
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["task_snapshot"]["title"] == "协作者已更新"
|
||||
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unscoped_history_keeps_original_user_isolation(store):
|
||||
personal = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id=None,
|
||||
task_snapshot={"id": "legacy-card-id", "title": "个人会话"},
|
||||
)
|
||||
|
||||
assert await store.get_session(personal["id"], "user-b") is None
|
||||
assert await store.list_sessions("user-b") == []
|
||||
assert await store.update_session(personal["id"], "user-b", status="active") is None
|
||||
assert not await store.delete_session(personal["id"], "user-b")
|
||||
|
||||
# Old client records could already carry a mock task id. The new database
|
||||
# marker keeps them personal after the migration; only a new explicit
|
||||
# route taskId creates a shared row.
|
||||
legacy_with_mock_task_id = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="legacy-mock-task-id",
|
||||
task_scoped=False,
|
||||
task_snapshot={"id": "legacy-card-id", "title": "鏃х増涓汉浼氳瘽"},
|
||||
)
|
||||
assert await store.get_session(legacy_with_mock_task_id["id"], "user-b") is None
|
||||
assert await store.list_sessions("user-b", external_task_id="legacy-mock-task-id") == []
|
||||
|
||||
|
||||
def test_session_create_allows_missing_task_id_and_scoped_snapshots_keep_scope():
|
||||
body = position_roundtable_router.SessionCreateRequest(
|
||||
external_task_id=" task-route-001 ",
|
||||
task_snapshot={"id": "wrong-id", "title": "任务"},
|
||||
)
|
||||
assert body.external_task_id == "task-route-001"
|
||||
assert position_roundtable_router._task_snapshot_for_scope(
|
||||
body.external_task_id,
|
||||
body.task_snapshot,
|
||||
) == {
|
||||
"id": "task-route-001",
|
||||
"externalTaskId": "task-route-001",
|
||||
"title": "任务",
|
||||
}
|
||||
assert position_roundtable_router.SessionCreateRequest(task_snapshot={}).external_task_id is None
|
||||
assert position_roundtable_router.SessionCreateRequest(external_task_id=" ").external_task_id is None
|
||||
assert position_roundtable_router._task_snapshot_for_scope(
|
||||
"legacy-mock-task-id",
|
||||
{"id": "legacy-card-id", "title": "旧记录"},
|
||||
task_scoped=False,
|
||||
) == {"id": "legacy-card-id", "title": "旧记录"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demo_seed_is_complete_idempotent_and_user_scoped(store):
|
||||
payload = {
|
||||
@@ -490,6 +882,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
|
||||
}
|
||||
session = {
|
||||
"id": "session-a",
|
||||
"external_task_id": "task-fallback",
|
||||
"status": "active",
|
||||
"task_snapshot": {},
|
||||
"chain_id": "chain-a",
|
||||
@@ -561,7 +954,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
||||
async def test_node_updates_are_user_scoped_without_task_id(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
@@ -602,7 +995,9 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
created = await store.create_session(
|
||||
"user-a", external_task_id="task-reject", task_snapshot={}
|
||||
)
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
@@ -682,7 +1077,7 @@ async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id=None,
|
||||
external_task_id="task-builtin-invalidation",
|
||||
task_snapshot={
|
||||
"title": "测试任务",
|
||||
"description": "形成协同方案",
|
||||
|
||||
@@ -116,3 +116,13 @@ async def test_unknown_mode_value_ignored_falls_through(_gw):
|
||||
await _run(gateway)
|
||||
assert captured["thinking_enabled"] is True
|
||||
assert captured["reasoning_effort"] == "low"
|
||||
|
||||
|
||||
def test_chain_seat_schema_keeps_human_validation_checkpoint():
|
||||
"""Chain-seat JSON must preserve the frontend Step 2 pause configuration."""
|
||||
from app.gateway.routers.roundtable_chains import ChainSeatSchema
|
||||
|
||||
assert ChainSeatSchema(agent_id="seat-1").human_validation is False
|
||||
assert ChainSeatSchema(agent_id="seat-1", human_validation=True).model_dump()[
|
||||
"human_validation"
|
||||
] is True
|
||||
Reference in new issue
Block a user