Skip to content

Commit 0863a84

Browse files
wjlgatechclaude
andcommitted
feat: Claude AI intelligence layer — real reasoning on every dashboard element
Architecture: Anthropic SDK (claude-sonnet-4-6) wired into the dashboard as a server-side intelligence layer. Every piece of text the user sees can be AI-interpreted — not templates, real Claude reasoning. AI endpoints: - POST /api/ai/interpret — explain proposals, positions, approval reasoning - POST /api/ai/chat — real conversational AI with full portfolio context - POST /api/ai/screenshot — Claude vision extracts holdings from broker screenshots Briefing API upgraded: - Headline generated by Claude (personalized to portfolio state + market) - Proposal context generated by Claude (personalized risk/reward explanation) - Graceful fallback to templates when Claude API is unavailable Dashboard upgrades: - Real AI chat panel (replaces fake keyword matcher) "Why these stocks?" → Claude answers with your specific portfolio context - Quick-question buttons that auto-send to Claude - "Claude is thinking..." indicator - "Powered by Claude" label for transparency Cost: ~$0.01-0.03 per AI call (Sonnet). ~$0.10 per full page load. Note: Current API key has hit workspace limit (resets Apr 1). Fallback templates work seamlessly when Claude API is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d70bcfd commit 0863a84

8 files changed

Lines changed: 537 additions & 33 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { NextRequest } from "next/server";
2+
import { validateRequest } from "@/lib/auth";
3+
import { apiSuccess, apiError } from "@/lib/errors";
4+
import { answerQuestion } from "@/lib/ai/claude";
5+
6+
/**
7+
* POST /api/ai/chat — Real AI chat with portfolio context
8+
*
9+
* Not a fake keyword matcher. Real Claude reasoning about the user's
10+
* specific portfolio, positions, and market situation.
11+
*/
12+
export async function POST(req: NextRequest) {
13+
const authErr = validateRequest(req);
14+
if (authErr) return authErr;
15+
16+
try {
17+
const body = await req.json();
18+
const { question, context } = body;
19+
20+
if (!question || typeof question !== "string") {
21+
return apiError("question is required", 400);
22+
}
23+
24+
const answer = await answerQuestion(question, context ?? {
25+
portfolioEquity: 0,
26+
positions: [],
27+
pendingApprovals: [],
28+
regime: "unknown",
29+
vix: 0,
30+
userLevel: "beginner",
31+
});
32+
33+
return apiSuccess({ answer });
34+
} catch (err) {
35+
return apiError("AI chat failed", 500);
36+
}
37+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest } from "next/server";
2+
import { validateRequest } from "@/lib/auth";
3+
import { apiSuccess, apiError } from "@/lib/errors";
4+
import { explainProposal, explainPosition, generateApprovalReasoning } from "@/lib/ai/claude";
5+
6+
/**
7+
* POST /api/ai/interpret — AI interprets a specific element
8+
*
9+
* Called when user clicks on a stock, position, or proposal.
10+
* Returns real Claude reasoning, not templates.
11+
*/
12+
export async function POST(req: NextRequest) {
13+
const authErr = validateRequest(req);
14+
if (authErr) return authErr;
15+
16+
try {
17+
const body = await req.json();
18+
const { type, context } = body;
19+
20+
if (type === "proposal") {
21+
const interpretation = await explainProposal(context);
22+
return apiSuccess({ interpretation });
23+
}
24+
25+
if (type === "position") {
26+
const interpretation = await explainPosition(context);
27+
return apiSuccess({ interpretation });
28+
}
29+
30+
if (type === "approval_reasoning") {
31+
const reasoning = await generateApprovalReasoning(context);
32+
return apiSuccess({ reasoning });
33+
}
34+
35+
return apiError("Unknown type. Use: proposal, position, approval_reasoning", 400);
36+
} catch (err) {
37+
return apiError("AI interpretation failed", 500);
38+
}
39+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { NextRequest } from "next/server";
2+
import { validateRequest } from "@/lib/auth";
3+
import { apiSuccess, apiError } from "@/lib/errors";
4+
import { extractPortfolioFromImage } from "@/lib/ai/claude";
5+
6+
/**
7+
* POST /api/ai/screenshot — Extract portfolio from broker screenshot
8+
*
9+
* User drops/uploads an image of their broker's positions page.
10+
* Claude vision reads it and extracts all holdings.
11+
*/
12+
export async function POST(req: NextRequest) {
13+
const authErr = validateRequest(req);
14+
if (authErr) return authErr;
15+
16+
try {
17+
const body = await req.json();
18+
const { image, mimeType } = body;
19+
20+
if (!image || typeof image !== "string") {
21+
return apiError("image (base64) is required", 400);
22+
}
23+
24+
const result = await extractPortfolioFromImage(image, mimeType ?? "image/png");
25+
26+
// Try to parse as JSON
27+
let holdings: unknown[] = [];
28+
try {
29+
const jsonMatch = result.match(/\[[\s\S]*\]/);
30+
if (jsonMatch) {
31+
holdings = JSON.parse(jsonMatch[0]);
32+
}
33+
} catch { /* Claude returned non-JSON, return raw text */ }
34+
35+
return apiSuccess({
36+
holdings,
37+
raw: result,
38+
message: `Extracted ${holdings.length} position${holdings.length !== 1 ? "s" : ""} from screenshot`,
39+
});
40+
} catch (err) {
41+
return apiError("Screenshot extraction failed", 500);
42+
}
43+
}

apps/screener-api/app/api/briefing/route.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { apiSuccess, apiError } from "@/lib/errors";
44
import { config } from "@/lib/config";
55
import { MoneyAgent } from "@/lib/agent/core";
66
import { TradeExecutor } from "@/lib/broker/executor";
7+
import { generateBriefing, explainProposal as aiExplainProposal } from "@/lib/ai/claude";
78

89
export async function GET(req: NextRequest) {
910
const authErr = validateRequest(req);
@@ -55,16 +56,26 @@ export async function GET(req: NextRequest) {
5556

5657
const pendingCount = report?.pendingApprovals?.length ?? 0;
5758

58-
// Headline
59+
// Headline — real Claude AI generates the briefing
5960
let headline: string;
60-
if (portfolio.positions.length > 0 && pendingCount > 0) {
61-
headline = `${greeting}. You have ${portfolio.positions.length} open position${portfolio.positions.length > 1 ? "s" : ""} and ${pendingCount} new opportunit${pendingCount > 1 ? "ies" : "y"} to review.`;
62-
} else if (pendingCount > 0) {
63-
headline = `${greeting}. I found ${pendingCount} opportunit${pendingCount > 1 ? "ies" : "y"} for you to review.`;
64-
} else if (portfolio.positions.length > 0) {
65-
headline = `${greeting}. Your ${portfolio.positions.length} position${portfolio.positions.length > 1 ? "s are" : " is"} open. No new opportunities today.`;
66-
} else {
67-
headline = `${greeting}. Markets are quiet. No actions taken, no new opportunities.`;
61+
try {
62+
headline = await generateBriefing({
63+
positions: positionsWithContext.map((p) => ({ symbol: p.symbol, pnlPct: p.pnlPct, value: p.posValue })),
64+
pendingCount,
65+
watchCount: report?.watching?.length ?? 0,
66+
regime: report?.market?.regime?.regime ?? "unknown",
67+
vix: report?.market?.vix ?? 0,
68+
recentActions: actionsSummary,
69+
totalEquity: portfolioValue,
70+
totalPnl: portfolioValue - config.initialCapital,
71+
});
72+
if (!headline) throw new Error("empty");
73+
} catch {
74+
// Fallback to template if Claude API fails
75+
const greeting = new Date().getHours() < 12 ? "Good morning" : new Date().getHours() < 17 ? "Good afternoon" : "Good evening";
76+
headline = portfolio.positions.length > 0
77+
? `${greeting}. ${portfolio.positions.length} positions open${pendingCount > 0 ? `, ${pendingCount} new opportunities to review` : ""}.`
78+
: `${greeting}. ${pendingCount > 0 ? `${pendingCount} opportunities to review.` : "Markets are quiet."}`;
6879
}
6980

7081
// Selection funnel (transparency)
@@ -89,10 +100,32 @@ export async function GET(req: NextRequest) {
89100
headline: "No market data yet. Run: npx tsx scripts/run-pipeline.ts",
90101
},
91102
actions: actionsSummary,
92-
pendingApprovals: (report?.pendingApprovals ?? []).map((p) => ({
93-
...p,
94-
aiContext: generateProposalContext(p, portfolioValue),
95-
})),
103+
pendingApprovals: await Promise.all(
104+
(report?.pendingApprovals ?? []).map(async (p) => {
105+
// Try real Claude AI interpretation, fall back to template
106+
let aiContext: string;
107+
try {
108+
aiContext = await aiExplainProposal({
109+
ticker: p.ticker,
110+
shares: p.shares,
111+
price: p.price,
112+
stopLoss: p.stopLoss,
113+
takeProfit: p.takeProfit,
114+
signals: p.signals,
115+
reason: p.reason,
116+
portfolioEquity: portfolioValue,
117+
existingPositions: portfolio.positions.map((pos) => pos.symbol),
118+
regime: report?.market?.regime?.regime ?? "unknown",
119+
vix: report?.market?.vix ?? 20,
120+
userLevel: "intermediate",
121+
});
122+
if (!aiContext) throw new Error("empty");
123+
} catch {
124+
aiContext = generateProposalContext(p, portfolioValue);
125+
}
126+
return { ...p, aiContext };
127+
})
128+
),
96129
watching: report?.watching ?? [],
97130
portfolio: {
98131
equity: portfolio.equity,

apps/screener-api/app/dashboard/page.tsx

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -380,26 +380,8 @@ export default function DashboardV2() {
380380
</div>
381381
</div>
382382

383-
{/* Quick Actions — replaces fake chat */}
384-
<div style={{ flex: 1, padding: "16px 16px" }}>
385-
<div style={sectionTitle}>QUICK ACTIONS</div>
386-
<div style={{ color: "#52525b", fontSize: 11, marginTop: 6, marginBottom: 10 }}>
387-
Open Claude and say any of these:
388-
</div>
389-
{[
390-
{ cmd: "/invest", desc: "GPS — say your goal, get a step-by-step plan" },
391-
{ cmd: "/screen", desc: "Scan for stocks near support/resistance" },
392-
{ cmd: "/strategy-lab", desc: "Test a trading idea from YouTube or a blog" },
393-
{ cmd: "/import-portfolio", desc: "Share a screenshot of your broker holdings" },
394-
{ cmd: "/signals", desc: "See today's technical signals" },
395-
{ cmd: "/macro-check", desc: "Market risk assessment" },
396-
].map((a) => (
397-
<div key={a.cmd} style={{ padding: "6px 0", borderTop: "1px solid #1e1e2e" }}>
398-
<span style={{ color: "#3b82f6", fontFamily: "monospace", fontSize: 12 }}>{a.cmd}</span>
399-
<div style={{ color: "#6b7280", fontSize: 11 }}>{a.desc}</div>
400-
</div>
401-
))}
402-
</div>
383+
{/* AI Chat — real Claude, not simulation */}
384+
<AIChatPanel briefing={b} />
403385
</div>
404386
</div>
405387
</div>
@@ -453,3 +435,102 @@ const btnStyle: React.CSSProperties = {
453435
background: "#1e1e2e", border: "1px solid #2e2e3e", color: "#a1a1aa",
454436
padding: "5px 12px", borderRadius: 5, cursor: "pointer", fontSize: 11,
455437
};
438+
439+
// ── AI Chat Panel (real Claude, not simulation) ──────────────
440+
441+
function AIChatPanel({ briefing }: { briefing: Briefing | null }) {
442+
const [messages, setMessages] = useState<Array<{ role: "user" | "ai"; text: string }>>([]);
443+
const [input, setInput] = useState("");
444+
const [thinking, setThinking] = useState(false);
445+
446+
const send = async (text?: string) => {
447+
const question = text ?? input.trim();
448+
if (!question) return;
449+
setInput("");
450+
setMessages((prev) => [...prev, { role: "user", text: question }]);
451+
setThinking(true);
452+
453+
try {
454+
const res = await fetch("/api/ai/chat", {
455+
method: "POST",
456+
headers: { ...authHeaders, "Content-Type": "application/json" },
457+
body: JSON.stringify({
458+
question,
459+
context: {
460+
portfolioEquity: briefing?.portfolio?.portfolioValue ?? 0,
461+
positions: briefing?.portfolio?.positions?.map((p) => ({ symbol: p.symbol, pnlPct: p.pnlPct })) ?? [],
462+
pendingApprovals: briefing?.pendingApprovals?.map((p) => ({ ticker: p.ticker, reason: p.reason })) ?? [],
463+
regime: briefing?.market?.regime?.regime ?? "unknown",
464+
vix: briefing?.market?.vix ?? 0,
465+
userLevel: "intermediate",
466+
},
467+
}),
468+
});
469+
const data = await res.json();
470+
setMessages((prev) => [...prev, { role: "ai", text: data.answer ?? data.error ?? "No response" }]);
471+
} catch {
472+
setMessages((prev) => [...prev, { role: "ai", text: "Failed to reach AI. Check that the server is running." }]);
473+
}
474+
setThinking(false);
475+
};
476+
477+
return (
478+
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
479+
<div style={{ padding: "12px 16px", borderBottom: "1px solid #1e1e2e" }}>
480+
<div style={sectionTitle}>ASK AI</div>
481+
<div style={{ color: "#52525b", fontSize: 10, marginTop: 4 }}>Powered by Claude — real reasoning about YOUR portfolio</div>
482+
</div>
483+
484+
<div style={{ flex: 1, overflowY: "auto", padding: "8px 12px", minHeight: 120 }}>
485+
{messages.length === 0 && (
486+
<div style={{ color: "#3f3f46", fontSize: 11, textAlign: "center", marginTop: 16, lineHeight: 1.6 }}>
487+
Ask anything about your portfolio, the market, or a specific stock.
488+
</div>
489+
)}
490+
{messages.map((m, i) => (
491+
<div key={i} style={{
492+
padding: "8px 12px", marginTop: 6, borderRadius: 8, fontSize: 12, lineHeight: 1.6,
493+
background: m.role === "user" ? "#1e3a5f" : "#111119",
494+
color: m.role === "user" ? "#93c5fd" : "#d4d4d8",
495+
marginLeft: m.role === "user" ? 30 : 0,
496+
marginRight: m.role === "ai" ? 10 : 0,
497+
}}>
498+
{m.text}
499+
</div>
500+
))}
501+
{thinking && (
502+
<div style={{ padding: "8px 12px", marginTop: 6, color: "#6b7280", fontSize: 11, fontStyle: "italic" }}>
503+
Claude is thinking...
504+
</div>
505+
)}
506+
</div>
507+
508+
{/* Quick questions */}
509+
<div style={{ display: "flex", gap: 4, padding: "4px 12px", flexWrap: "wrap" }}>
510+
{["Why these stocks?", "What's my risk?", "Should I approve?", "Market outlook"].map((q) => (
511+
<button key={q} onClick={() => send(q)} style={{
512+
background: "none", border: "1px solid #2e2e3e", color: "#6b7280",
513+
padding: "4px 8px", borderRadius: 4, cursor: "pointer", fontSize: 10,
514+
}}>{q}</button>
515+
))}
516+
</div>
517+
518+
{/* Input */}
519+
<div style={{ display: "flex", gap: 6, padding: "8px 12px" }}>
520+
<input
521+
value={input}
522+
onChange={(e) => setInput(e.target.value)}
523+
onKeyDown={(e) => e.key === "Enter" && send()}
524+
placeholder="Ask anything..."
525+
style={{
526+
flex: 1, background: "#111119", border: "1px solid #2e2e3e", color: "#e2e8f0",
527+
padding: "8px 12px", borderRadius: 6, fontSize: 12, outline: "none",
528+
}}
529+
/>
530+
<button onClick={() => send()} disabled={thinking} style={{ ...btnStyle, background: "#3b82f6", color: "#fff" }}>
531+
{thinking ? "..." : "Ask"}
532+
</button>
533+
</div>
534+
</div>
535+
);
536+
}

0 commit comments

Comments
 (0)