deerflow-code/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py
2026-09-07 18:24:55 +08:00

351 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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