Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions backend/src/analytics_agent/agent/proposals_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
]
)
"""
Expand Down
19 changes: 19 additions & 0 deletions backend/src/analytics_agent/skills/improve-context/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand All @@ -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.**
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
10 changes: 10 additions & 0 deletions frontend/src/components/Chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SSEEvent>,
userText: string
) => void;
}

export function MessageList({
Expand All @@ -38,6 +43,7 @@ export function MessageList({
showReasoning = true,
onChartError,
onProposalStream,
onProposalRefineStream,
}: Props) {
const bottomRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -123,6 +129,11 @@ export function MessageList({
onStream={(stream, userPayload) =>
onProposalStream?.(stream, userPayload)
}
onRefineStream={
onProposalRefineStream
? (stream, userText) => onProposalRefineStream(stream, userText)
: undefined
}
/>
</div>
);
Expand Down
122 changes: 118 additions & 4 deletions frontend/src/components/Chat/messages/ProposalsMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<import("@/types").SSEEvent>,
userText: string
) => void;
}

const WRITE_MODE_META: Record<
NonNullable<ProposalItem["write_mode"]>,
{ 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: <ShieldCheck className="w-3 h-3" />,
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: <ShieldAlert className="w-3 h-3" />,
tooltip: "Touches shared DataHub metadata — will be queued for review.",
},
};

const KIND_META: Record<
ProposalItem["kind"],
{ label: string; className: string; icon: React.ReactNode }
Expand All @@ -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<Set<string>>(() => new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [refineText, setRefineText] = useState("");
const [isRefining, setIsRefining] = useState(false);
const refineRef = useRef<HTMLTextAreaElement | null>(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) => {
Expand Down Expand Up @@ -157,6 +222,19 @@ export function ProposalsMessage({ messageId, conversationId, payload, submitted
{meta.icon}
{meta.label}
</span>
{/* Write-mode badge */}
{(() => {
const wm = WRITE_MODE_META[proposal.write_mode ?? "needs_approval"];
return (
<span
title={wm.tooltip}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium ${wm.className}`}
>
{wm.icon}
{wm.label}
</span>
);
})()}
<span className="text-sm font-medium text-foreground truncate">
{proposal.title}
</span>
Expand All @@ -177,6 +255,42 @@ export function ProposalsMessage({ messageId, conversationId, payload, submitted
})}
</ul>

{/* Chat refinement — ask follow-ups or request edits before submitting */}
{onRefineStream && !submitted && (
<div className="px-4 py-2 border-t border-border bg-muted/10">
<div className="flex items-end gap-2">
<textarea
ref={refineRef}
value={refineText}
onChange={(e) => setRefineText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleRefine();
}
}}
placeholder="Ask a follow-up or request a change (e.g. &ldquo;drop #3, add one about churn&rdquo;)…"
rows={1}
disabled={isSubmitting || isRefining}
className="flex-1 resize-none rounded-md border border-border bg-background px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/70 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-60"
/>
<button
type="button"
onClick={(e) => { e.stopPropagation(); void handleRefine(); }}
disabled={!refineText.trim() || isSubmitting || isRefining}
aria-label="Send refinement"
className="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
>
{isRefining ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
)}

{/* Footer */}
<div className="flex items-center justify-between gap-3 px-4 py-3 border-t border-border bg-muted/20">
{/* Select all / none */}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export interface ProposalItem {
title: string;
detail: string;
target?: { urn?: string; field_path?: string } | null;
/** "direct" — user-scoped, applies immediately on submit.
* "needs_approval" — touches shared DataHub metadata, will be queued for review.
* Defaults to "needs_approval" if the agent omits it. */
write_mode?: "direct" | "needs_approval";
}

export interface ProposalsPayload {
Expand Down