diff --git a/backend/src/analytics_agent/agent/proposals_tool.py b/backend/src/analytics_agent/agent/proposals_tool.py index ad8212c..cefd340 100644 --- a/backend/src/analytics_agent/agent/proposals_tool.py +++ b/backend/src/analytics_agent/agent/proposals_tool.py @@ -20,6 +20,13 @@ class ProposalItem(BaseModel): title: str detail: str target: dict | None = None # e.g. {"urn": "...", "field_path": "..."} + # "direct" → applying this writes only to user-scoped state + # (private docs, agent memory, personal prefs) + # "needs_approval" → applying this mutates shared metadata in DataHub + # (column descriptions, glossary, team/global docs) + # The frontend renders a badge per proposal so the user sees the + # blast radius before submitting. + write_mode: Literal["direct", "needs_approval"] = "needs_approval" @tool @@ -42,16 +49,25 @@ async def present_proposals( - title: short title for the proposal - detail: 1-2 sentence description of what to add/change - target: optional dict with "urn" and/or "field_path" for existing entities + - write_mode: "direct" (user-scoped, writes immediately) or + "needs_approval" (touches shared DataHub metadata). Defaults to + "needs_approval". Use "direct" only when the change is scoped + to the user — private docs, personal preferences, agent memory. Example: present_proposals( prompt="Based on our conversation, here are 3 documentation improvements:", proposals=[ {"id": "1", "kind": "new_doc", "title": "Revenue Metrics Guide", - "detail": "Define net ARR vs gross ARR and specify the revenue table."}, + "detail": "Define net ARR vs gross ARR and specify the revenue table.", + "write_mode": "needs_approval"}, {"id": "2", "kind": "fix_description", "title": "orders.status column", "detail": "Current description is empty. Values: pending, confirmed, shipped.", - "target": {"urn": "urn:li:dataset:...", "field_path": "status"}}, + "target": {"urn": "urn:li:dataset:...", "field_path": "status"}, + "write_mode": "needs_approval"}, + {"id": "3", "kind": "new_doc", "title": "My ARR analysis notes", + "detail": "Save this thread's findings to a private folder.", + "write_mode": "direct"}, ] ) """ diff --git a/backend/src/analytics_agent/skills/improve-context/SKILL.md b/backend/src/analytics_agent/skills/improve-context/SKILL.md index 1b5a23b..81b13e5 100644 --- a/backend/src/analytics_agent/skills/improve-context/SKILL.md +++ b/backend/src/analytics_agent/skills/improve-context/SKILL.md @@ -54,6 +54,12 @@ Each proposal must include: - `title`: short label (e.g. `"Revenue Metrics Guide"`) - `detail`: 1–2 sentence description of what to add or change - `target` (optional): `{"urn": "...", "field_path": "..."}` for fix_description proposals that target a known entity +- `write_mode`: `"needs_approval"` for changes to shared DataHub metadata + (column descriptions, glossary terms, team/global docs — anything other + users will see), or `"direct"` for user-scoped changes (private docs, + personal notes, agent memory). The UI renders this as a badge so the user + sees the blast radius before submitting. Default to `"needs_approval"` + unless you can clearly establish the change is scoped to the user only. Example call: ``` @@ -73,6 +79,19 @@ present_proposals( Do **not** call any write-back tools until the user explicitly selects proposals and submits. +#### Refining proposals via the in-card chat + +The proposals card includes a chat input so the user can ask follow-ups +("explain #2 more", "add one about X", "drop #3"). Those messages arrive +with `source: "proposal_chat"` and an `origin_message_id` pointing back to +the original proposals card. When you receive one: + +- If the user wants clarification only, answer briefly in plain text — do + not re-emit the card. +- If the user wants to add, drop, or restructure proposals, call + `present_proposals` again with the revised list. A new card will appear; + the prior card remains in the transcript for context. + ### Step 5 — Execute approved changes directly > **Note: the user has already approved these changes via the proposals card; do NOT ask for another confirmation.** diff --git a/frontend/src/api/conversations.ts b/frontend/src/api/conversations.ts index f858047..d99322d 100644 --- a/frontend/src/api/conversations.ts +++ b/frontend/src/api/conversations.ts @@ -88,3 +88,19 @@ export function sendProposalSelection( selected_ids: selectedIds, }); } + +/** + * Send a free-text refinement message tied to a specific proposals card. + * Renders as a normal user bubble, but tags `source: "proposal_chat"` so + * the agent (and downstream UI) can correlate the turn with its origin card. + */ +export function sendProposalRefinement( + conversationId: string, + originMessageId: string, + text: string +) { + return streamMessage(conversationId, text, undefined, { + source: "proposal_chat", + origin_message_id: originMessageId, + }); +} diff --git a/frontend/src/components/Chat/ChatView.tsx b/frontend/src/components/Chat/ChatView.tsx index 56dceac..cf82829 100644 --- a/frontend/src/components/Chat/ChatView.tsx +++ b/frontend/src/components/Chat/ChatView.tsx @@ -373,6 +373,16 @@ export function ChatView() { }); await consumeStream(stream, activeId); }} + onProposalRefineStream={async (stream, userText) => { + if (!activeId || isStreaming) return; + appendMessage({ + id: crypto.randomUUID(), + event_type: "TEXT", + role: "user", + payload: { text: userText }, + }); + await consumeStream(stream, activeId); + }} onChartError={(error) => { if (chartErrorRetried.current || isStreaming) return; chartErrorRetried.current = true; diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index b7cee2b..24ca17b 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -29,6 +29,11 @@ interface Props { selected_ids: string[]; } ) => void; + /** Refinement chat from inside a proposals card; renders as a normal user bubble. */ + onProposalRefineStream?: ( + stream: AsyncIterator, + userText: string + ) => void; } export function MessageList({ @@ -38,6 +43,7 @@ export function MessageList({ showReasoning = true, onChartError, onProposalStream, + onProposalRefineStream, }: Props) { const bottomRef = useRef(null); @@ -123,6 +129,11 @@ export function MessageList({ onStream={(stream, userPayload) => onProposalStream?.(stream, userPayload) } + onRefineStream={ + onProposalRefineStream + ? (stream, userText) => onProposalRefineStream(stream, userText) + : undefined + } /> ); diff --git a/frontend/src/components/Chat/messages/ProposalsMessage.tsx b/frontend/src/components/Chat/messages/ProposalsMessage.tsx index 2c5c7e6..e5f2d14 100644 --- a/frontend/src/components/Chat/messages/ProposalsMessage.tsx +++ b/frontend/src/components/Chat/messages/ProposalsMessage.tsx @@ -5,12 +5,22 @@ * transitions to a disabled read-only state. */ -import { useState, useEffect } from "react"; -import { FileText, FilePlus, Tag, CheckSquare, Square, Loader2 } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { + FileText, + FilePlus, + Tag, + CheckSquare, + Square, + Loader2, + Send, + ShieldCheck, + ShieldAlert, +} from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ProposalItem, ProposalsPayload } from "@/types"; -import { sendProposalSelection } from "@/api/conversations"; +import { sendProposalSelection, sendProposalRefinement } from "@/api/conversations"; interface Props { messageId: string; @@ -26,8 +36,37 @@ interface Props { selected_ids: string[]; } ) => void; + /** + * Send a free-text refinement turn tied to this card. Optional — if not + * provided, the chat input is hidden. Same shape as ChatView's primary + * stream handler so the message renders as a normal user bubble. + */ + onRefineStream?: ( + stream: AsyncIterator, + userText: string + ) => void; } +const WRITE_MODE_META: Record< + NonNullable, + { label: string; className: string; icon: React.ReactNode; tooltip: string } +> = { + direct: { + label: "Writes directly", + className: + "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800", + icon: , + tooltip: "Scoped to your account — applies immediately on submit.", + }, + needs_approval: { + label: "Needs approval", + className: + "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800", + icon: , + tooltip: "Touches shared DataHub metadata — will be queued for review.", + }, +}; + const KIND_META: Record< ProposalItem["kind"], { label: string; className: string; icon: React.ReactNode } @@ -49,16 +88,42 @@ const KIND_META: Record< }, }; -export function ProposalsMessage({ messageId, conversationId, payload, submitted, onStream }: Props) { +export function ProposalsMessage({ + messageId, + conversationId, + payload, + submitted, + onStream, + onRefineStream, +}: Props) { const { proposals } = payload; const [selected, setSelected] = useState>(() => new Set()); const [isSubmitting, setIsSubmitting] = useState(false); + const [refineText, setRefineText] = useState(""); + const [isRefining, setIsRefining] = useState(false); + const refineRef = useRef(null); // Clear the spinner once the parent confirms submission (selection chip appended) useEffect(() => { if (submitted) setIsSubmitting(false); }, [submitted]); + const handleRefine = async () => { + const text = refineText.trim(); + if (!text || isRefining || submitted || isSubmitting || !onRefineStream) return; + setIsRefining(true); + try { + const stream = sendProposalRefinement(conversationId, messageId, text); + onRefineStream(stream, text); + setRefineText(""); + } catch { + // Surface failure by leaving the text in place; the parent stream + // handler will render any backend error event normally. + } finally { + setIsRefining(false); + } + }; + const toggle = (id: string) => { if (submitted || isSubmitting) return; setSelected((prev) => { @@ -157,6 +222,19 @@ export function ProposalsMessage({ messageId, conversationId, payload, submitted {meta.icon} {meta.label} + {/* Write-mode badge */} + {(() => { + const wm = WRITE_MODE_META[proposal.write_mode ?? "needs_approval"]; + return ( + + {wm.icon} + {wm.label} + + ); + })()} {proposal.title} @@ -177,6 +255,42 @@ export function ProposalsMessage({ messageId, conversationId, payload, submitted })} + {/* Chat refinement — ask follow-ups or request edits before submitting */} + {onRefineStream && !submitted && ( +
+
+