"""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"), )