351 lines
16 KiB
Python
351 lines
16 KiB
Python
"""Roundtable-draft sharing API.
|
||
|
||
A user creates a share code for *one* of their roundtable-planning drafts (a full
|
||
会商 session). Two modes:
|
||
|
||
- ``import`` — redeeming the code copies the draft into the redeemer's account as
|
||
an independent copy they fully own (fresh draft id, deduped per recipient).
|
||
- ``view`` — a public, read-only page that renders the draft's Step 3 result
|
||
report (the self-contained 方案总览 HTML). Anyone with the link can open it
|
||
without logging in.
|
||
|
||
Mirrors ``app/gateway/routers/thread_shares.py`` but the shared resource is a
|
||
``roundtable_drafts`` row (read via ``app.state.roundtable_draft_store``) rather
|
||
than a conversation thread.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.deps import get_roundtable_draft_share_store
|
||
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore
|
||
from deerflow.runtime.user_context import get_effective_user_id
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/roundtable-draft-shares", tags=["roundtable-draft-shares"])
|
||
|
||
# Public, unauthenticated router. Its prefix is whitelisted in
|
||
# ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``) — only read-only,
|
||
# intentionally world-readable endpoints belong here.
|
||
public_router = APIRouter(prefix="/api/public/roundtable-shares", tags=["roundtable-draft-shares-public"])
|
||
|
||
_UNTITLED = "未命名会商"
|
||
_SHARE_MODES = ("import", "view")
|
||
|
||
|
||
def _current_user_id(request: Request) -> str:
|
||
user = getattr(request.state, "user", None)
|
||
if user is not None:
|
||
return str(user.id)
|
||
return get_effective_user_id()
|
||
|
||
|
||
def _get_draft_store(request: Request):
|
||
store = getattr(request.app.state, "roundtable_draft_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
|
||
return store
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Request / response models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class CreateShareRequest(BaseModel):
|
||
draft_id: str = Field(..., description="The roundtable draft to share")
|
||
mode: str = Field(
|
||
default="import",
|
||
description='"import" — recipient copies the draft; "view" — public read-only result page',
|
||
)
|
||
|
||
|
||
class ShareResponse(BaseModel):
|
||
share_code: str = Field(..., description="Short code that recipients enter to import")
|
||
owner_user_id: str = Field(..., description="User who created the share")
|
||
draft_id: str = Field(..., description="The shared draft")
|
||
revoked: bool = Field(default=False, description="Whether the code has been revoked")
|
||
mode: str = Field(default="import", description='"import" or "view"')
|
||
created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)")
|
||
|
||
|
||
class SharesListResponse(BaseModel):
|
||
shares: list[ShareResponse]
|
||
|
||
|
||
class SharePreviewResponse(BaseModel):
|
||
share_code: str
|
||
shared_by: str = Field(..., description="Owner user id of the shared draft")
|
||
title: str = Field(..., description="Title of the shared draft")
|
||
|
||
|
||
class ImportResultResponse(BaseModel):
|
||
imported: int = Field(..., description="1 when the draft was copied, else 0")
|
||
skipped: int = Field(..., description="1 when it was already imported, else 0")
|
||
draft_id: str | None = Field(default=None, description="The new local draft id (when imported)")
|
||
|
||
|
||
class SeatDeliveryItem(BaseModel):
|
||
"""One sub-agent's final delivery from the Step 2 roundtable."""
|
||
|
||
sender: str = ""
|
||
content: str = ""
|
||
|
||
|
||
class PublicDraftShareSnapshotResponse(BaseModel):
|
||
"""Read-only roundtable-result snapshot served to anonymous viewers.
|
||
|
||
Mirrors what the in-app Step 3「最终方案展示」(`HighFidelityReport`) shows, minus
|
||
the live per-seat output files (those need authenticated thread access):
|
||
总控最终结论 + 各席位最终交付 + the generated 结果绘制 report.
|
||
"""
|
||
|
||
share_code: str
|
||
title: str = Field(..., description="Draft title")
|
||
mode: str = Field(default="view")
|
||
# 总控最终结论 —— the coordinator's final consensus text (Step 2 lastLeaderContent).
|
||
consensus_text: str = Field(default="", description="Coordinator's final consensus (markdown)")
|
||
# 各席位最终交付 —— each sub-agent's final delivery, filtered from Step 2 dialogues.
|
||
seat_deliveries: list[SeatDeliveryItem] = Field(default_factory=list)
|
||
# The Step 3「结果绘制」report — a self-contained HTML document. Empty when the
|
||
# shared draft never reached / generated a result report.
|
||
html: str = Field(default="", description="Self-contained Step 3 result report HTML (经典 HTML 看板)")
|
||
summary: str | None = Field(default=None, description="The report's closing one-liner")
|
||
generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)")
|
||
has_report: bool = Field(default=False, description="Whether a Step 3 HTML report exists")
|
||
# Step 3「方案总结报告」—— the Markdown report from roundtable-summary (step3.summaryReport.md).
|
||
summary_report_md: str = Field(default="", description="Step 3 Markdown 方案总结报告")
|
||
has_summary_report: bool = Field(default=False, description="Whether a Step 3 Markdown summary report exists")
|
||
# Step 3「大屏多页」(模式二) —— structured dashboard data (step3.dashboardJson); the
|
||
# frontend renders it to self-contained HTML via renderDashboardHtml at view time.
|
||
dashboard_json: str | None = Field(default=None, description="Step 3 大屏多页 structured data (report-json)")
|
||
has_dashboard: bool = Field(default=False, description="Whether a Step 3 大屏多页 dashboard exists")
|
||
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Owner side — create / list / revoke
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("", response_model=ShareResponse, summary="Create a share code for one roundtable draft")
|
||
async def create_share(
|
||
body: CreateShareRequest,
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> ShareResponse:
|
||
me = _current_user_id(request)
|
||
mode = body.mode if body.mode in _SHARE_MODES else "import"
|
||
# Only the draft's owner may share it.
|
||
draft = await _get_draft_store(request).get_draft(body.draft_id, me)
|
||
if draft is None:
|
||
raise HTTPException(status_code=404, detail="Draft not found")
|
||
# Reuse an existing active code (of the same mode) so re-sharing is idempotent.
|
||
existing = await share_store.get_active_for_draft(me, body.draft_id, mode=mode)
|
||
if existing is not None:
|
||
return ShareResponse(**existing)
|
||
record = await share_store.create(me, body.draft_id, mode=mode)
|
||
return ShareResponse(**record)
|
||
|
||
|
||
@router.get("", response_model=SharesListResponse, summary="List my roundtable-draft share codes")
|
||
async def list_shares(
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> SharesListResponse:
|
||
records = await share_store.list_for_owner(_current_user_id(request))
|
||
return SharesListResponse(shares=[ShareResponse(**r) for r in records])
|
||
|
||
|
||
@router.delete("/{share_code}", summary="Revoke a roundtable-draft share code")
|
||
async def revoke_share(
|
||
share_code: str,
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> dict[str, bool]:
|
||
ok = await share_store.revoke(share_code, _current_user_id(request))
|
||
if not ok:
|
||
raise HTTPException(status_code=404, detail="Share code not found")
|
||
return {"success": True}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recipient side — preview / import
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def _resolve_active_share(share_code: str, share_store: RoundtableDraftShareStore) -> dict:
|
||
share = await share_store.get(share_code)
|
||
if share is None:
|
||
raise HTTPException(status_code=404, detail="Share code not found")
|
||
if share.get("revoked"):
|
||
raise HTTPException(status_code=410, detail="This share code has been revoked")
|
||
return share
|
||
|
||
|
||
@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared roundtable draft")
|
||
async def preview_share(
|
||
share_code: str,
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> SharePreviewResponse:
|
||
share = await _resolve_active_share(share_code, share_store)
|
||
owner = share["owner_user_id"]
|
||
draft = await _get_draft_store(request).get_draft(share["draft_id"], owner)
|
||
if draft is None:
|
||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||
return SharePreviewResponse(
|
||
share_code=share_code,
|
||
shared_by=owner,
|
||
title=draft.get("title") or _UNTITLED,
|
||
)
|
||
|
||
|
||
def _imported_draft_id(share_code: str, recipient: str) -> str:
|
||
"""Deterministic per-(code, recipient) draft id so re-import is idempotent.
|
||
|
||
``roundtable_drafts.id`` is a global PK, so the id must be unique across
|
||
users — hence the recipient is folded into the hash rather than reusing a
|
||
code-only id (which would collide when two people import the same code).
|
||
"""
|
||
digest = hashlib.sha1(f"{share_code}:{recipient}".encode()).hexdigest()
|
||
return f"imp_{digest[:40]}"
|
||
|
||
|
||
@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared roundtable draft")
|
||
async def import_share(
|
||
share_code: str,
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> ImportResultResponse:
|
||
share = await _resolve_active_share(share_code, share_store)
|
||
if share.get("mode") == "view":
|
||
raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing")
|
||
owner = share["owner_user_id"]
|
||
origin_id = share["draft_id"]
|
||
recipient = _current_user_id(request)
|
||
if owner == recipient:
|
||
raise HTTPException(status_code=400, detail="You cannot import your own share code")
|
||
|
||
draft_store = _get_draft_store(request)
|
||
src = await draft_store.get_draft(origin_id, owner)
|
||
if src is None:
|
||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||
|
||
# Dedup: a deterministic id per (code, recipient) makes re-import a no-op.
|
||
new_id = _imported_draft_id(share_code, recipient)
|
||
if await draft_store.get_draft(new_id, recipient) is not None:
|
||
return ImportResultResponse(imported=0, skipped=1, draft_id=new_id)
|
||
|
||
await draft_store.create_draft(
|
||
recipient,
|
||
{
|
||
"id": new_id,
|
||
"title": src.get("title") or _UNTITLED,
|
||
"furthest_step": src.get("furthest_step") or 1,
|
||
"step1": src.get("step1"),
|
||
"step2": src.get("step2"),
|
||
"step3": src.get("step3"),
|
||
},
|
||
)
|
||
logger.info("Roundtable share %s imported by %s as draft %s", share_code, recipient, new_id)
|
||
return ImportResultResponse(imported=1, skipped=0, draft_id=new_id)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public read-only view — no authentication required
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _result_sections(draft: dict) -> tuple[str, list[dict[str, str]]]:
|
||
"""Derive (总控最终结论, 各席位最终交付) from a draft's Step 2 snapshot.
|
||
|
||
Mirrors the frontend draft hydration (`RoundtablePlanningPage.normalizeStep2Runs`):
|
||
when the draft has ``runs[]`` the rendered content is the **active run**
|
||
(``activeRunId``), NOT the top-level ``step2`` fields — those can be a stale
|
||
foreground seed (e.g. only the 系统消息/开场 2 条气泡) left over after a later
|
||
background job wrote its results into ``runs[]``. Reading the top level there
|
||
yields an empty consensus and no deliveries (the reported bug). Only drafts
|
||
with no ``runs[]`` (legacy/foreground-only) fall back to the top-level fields.
|
||
|
||
The consensus is ``lastLeaderContent`` and each seat delivery is a dialogue
|
||
bubble tagged ``confidence == "子智能体交付"``.
|
||
"""
|
||
step2 = draft.get("step2") if isinstance(draft.get("step2"), dict) else {}
|
||
step2 = step2 or {}
|
||
|
||
runs = [r for r in step2.get("runs", []) if isinstance(r, dict)] if isinstance(step2.get("runs"), list) else []
|
||
if runs:
|
||
active_id = step2.get("activeRunId")
|
||
source = next((r for r in runs if r.get("id") == active_id), None) or runs[0]
|
||
else:
|
||
source = step2
|
||
|
||
consensus = source.get("lastLeaderContent") or ""
|
||
dialogues = source.get("step2RoundtableDialogues")
|
||
if not isinstance(dialogues, list):
|
||
dialogues = []
|
||
|
||
seat_deliveries = [
|
||
{"sender": str(d.get("sender") or ""), "content": str(d.get("content") or "")}
|
||
for d in dialogues
|
||
if isinstance(d, dict) and d.get("confidence") == "子智能体交付"
|
||
]
|
||
return consensus, seat_deliveries
|
||
|
||
|
||
@public_router.get(
|
||
"/{share_code}",
|
||
response_model=PublicDraftShareSnapshotResponse,
|
||
summary="Public read-only snapshot of a view-shared roundtable draft",
|
||
)
|
||
async def get_public_draft_share_snapshot(
|
||
share_code: str,
|
||
request: Request,
|
||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||
) -> PublicDraftShareSnapshotResponse:
|
||
"""Serve a frozen, read-only roundtable result. Anyone with the link can read it.
|
||
|
||
Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode
|
||
codes all return 404 so the endpoint never reveals which case applies.
|
||
"""
|
||
share = await share_store.get(share_code)
|
||
if share is None or share.get("revoked") or share.get("mode") != "view":
|
||
raise HTTPException(status_code=404, detail="Share not found")
|
||
|
||
draft = await _get_draft_store(request).get_draft(share["draft_id"], share["owner_user_id"])
|
||
if draft is None:
|
||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||
|
||
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else {}
|
||
step3 = step3 or {}
|
||
html = step3.get("html") or ""
|
||
# 方案总结报告(Markdown,模式:roundtable-summary)—— 嵌在 step3.summaryReport.md。
|
||
summary_report = step3.get("summaryReport") if isinstance(step3.get("summaryReport"), dict) else {}
|
||
summary_report_md = (summary_report or {}).get("md") or ""
|
||
# 大屏多页(模式二)—— 只存结构化数据 step3.dashboardJson,前端按主题渲染成自包含 HTML。
|
||
dashboard_json = step3.get("dashboardJson") or None
|
||
consensus_text, seat_deliveries = _result_sections(draft)
|
||
return PublicDraftShareSnapshotResponse(
|
||
share_code=share_code,
|
||
title=draft.get("title") or _UNTITLED,
|
||
mode="view",
|
||
consensus_text=consensus_text,
|
||
seat_deliveries=[SeatDeliveryItem(**s) for s in seat_deliveries],
|
||
html=html,
|
||
summary=step3.get("summary"),
|
||
generated_at=step3.get("generatedAt"),
|
||
has_report=bool(html),
|
||
summary_report_md=summary_report_md,
|
||
has_summary_report=bool(summary_report_md),
|
||
dashboard_json=dashboard_json,
|
||
has_dashboard=bool(dashboard_json),
|
||
created_at=share.get("created_at"),
|
||
)
|