feat: taskId 深链会商按任务隔离草稿 + 第三步自动结构化流程图
- 深链 ?taskId=&goPath=*-A 透传 taskId 进会商,按 taskId 隔离存取草稿(一个 taskId 多条、可新建);
后端新增 GET /api/roundtable-drafts/by-task/{id} 列表 + RoundtableJobRepository.status_map_by_drafts 富化。
- 第三步生成 md 报告后自动结构化输出流程图(仅 taskId 模式):有报告基于报告、无报告基于各席位交付,
整段内联数据来源逐条抽取;层级优先用业务链总控提示词写明的层级,否则用 stages 的 goal。
- 流程图节点加 group(层级名)标签 + detail(条目名称+理由);强 prompt 压制 coordinatorPrompt 报告锚定,
禁阶段编号节点;FLOW_STRUCTURE_RULE 精确画法(根=任务、每层每条成节点、按对应关系串分支、排除汇总段落)。
- flow-graph 解析容错:JSON.parse 失败退回正则逐字段抽取,兼容裸换行/尾逗号,避免节点被整体丢弃。
- 修:重开第二步后第三步基于新一轮分析重新生成(复位 step3 闸门 + 中断旧三流)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e6a41dbc97
commit
fca39eeaee
17 files changed
+472
-37
No files matched your search
@@ -245,7 +245,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. **External-task binding (`task_id`)**: drafts carry an optional `task_id` (deep-link / 无界 embed); task-bound drafts are **shared** (the store ignores `user_id` for them, so a task's drafts span users). `GET /by-task/{task_id}/latest` - newest draft for a task (回显最新一条); `GET /by-task/{task_id}` - **all** drafts for a task, newest-first (taskId-mode history dropdown: 一个 taskId 多条记录), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
|
||||
+2
@@ -74,6 +74,8 @@
|
||||
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
|
||||
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
|
||||
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
|
||||
- **节点 label 命名约束(重要)**:label 必须是该层级 / 步骤对应的**具体概念或实体名**(如 目的 / 行为体 / 驱动因素,或它们下面的具体条目),**绝不能**写成「阶段1 / 阶段2 / 第N阶段 / 阶段几」这类阶段编号或泛称,也不要把「阶段」「环节」本身当成节点;不要在节点里塞阶段名称等与层级无关的内容;
|
||||
- **按业务链条层级组织**:当本轮 prompt 给了「业务链层级」(如 任务→目的→行为体→驱动因素)时,以「任务」为根节点按该层级自上而下逐层展开(每层放该层级下提炼出的具体节点);总控编排提示里明确写了层级 / 结构时,以总控提示词为准(最高优先级);
|
||||
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
|
||||
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
|
||||
|
||||
|
||||
@@ -194,6 +194,32 @@ async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftRespo
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=DraftListResponse)
|
||||
async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse:
|
||||
"""按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts`
|
||||
一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
rows = await store.list_drafts_by_task(task_id)
|
||||
job_store = getattr(request.app.state, "roundtable_job_store", None)
|
||||
if job_store is not None and rows:
|
||||
try:
|
||||
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
|
||||
except AttributeError:
|
||||
status_map = {}
|
||||
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
|
||||
logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True)
|
||||
status_map = {}
|
||||
for r in rows:
|
||||
info = status_map.get(r.get("id"))
|
||||
if info:
|
||||
r["status"] = info.get("status")
|
||||
r["job_id"] = info.get("jobId")
|
||||
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
+15
@@ -100,6 +100,21 @@ class RoundtableDraftRepository:
|
||||
row = result.scalars().first()
|
||||
return self._draft_to_dict(row) if row is not None else None
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
|
||||
"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._draft_to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableDraftRow(
|
||||
|
||||
+30
@@ -236,6 +236,36 @@ class RoundtableJobRepository:
|
||||
out[draft_id] = {"jobId": job_id, "status": status}
|
||||
return out
|
||||
|
||||
async def status_map_by_drafts(
|
||||
self,
|
||||
draft_ids: list[str],
|
||||
*,
|
||||
limit: int = 400,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
|
||||
|
||||
Like :meth:`status_map_by_user` but filtered by an explicit draft-id list
|
||||
instead of by owner — used by the taskId-mode history dropdown, where a
|
||||
task's drafts may span multiple users (task-bound drafts are shared).
|
||||
"""
|
||||
ids = [d for d in draft_ids if d]
|
||||
if not ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
|
||||
.where(RoundtableJobRow.draft_id.in_(ids))
|
||||
.order_by(RoundtableJobRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
for job_id, draft_id, status in result.all():
|
||||
if draft_id is None or draft_id in out:
|
||||
continue
|
||||
out[draft_id] = {"jobId": job_id, "status": status}
|
||||
return out
|
||||
|
||||
# ── delete / cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
async def delete(
|
||||
|
||||
Reference in new issue
Block a user