Skip to content
Merged
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
13 changes: 5 additions & 8 deletions app/api/research/agent-flow/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createClient } from "@/lib/supabase/server";
import { aiJsonWithFallback, configuredAiProviders } from "@/lib/ai-fallback";
import { getBitgetAsset, getBitgetCandles, getBitgetStockPerp, STOCK_PERP_UNIVERSE } from "@/lib/bitget-market";
import { buildSignals, prepareBacktestCandles } from "@/lib/backtest";
import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessments, type FlowAssessment, type FlowSource } from "@/lib/agent-flow-policy";
import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessments, sourceOnlyAssessment, type FlowAssessment, type FlowSource } from "@/lib/agent-flow-policy";
import { gatherIssuerEvidence, type FlowEvidence } from "@/lib/agent-flow-sources";
import { agentKeyIssuerEvidence } from "@/lib/agentkey-flow-sources";

Expand All @@ -23,14 +23,11 @@ async function settleWithin<T>(task: Promise<T>, timeoutMs: number): Promise<T>
if (timer) clearTimeout(timer);
}
}
const HOLD: FlowAssessment = { summary: "The available source headlines do not support a reliable directional assessment. Review the linked items and the issuer's original filings.", stance: "unclear", confidence: 0, citedUrls: [], supportingEvidence: [], counterEvidence: [], risks: ["Source headlines may omit material context."], nextCheck: "Review original issuer filings and fresh completed candles." };

function sourceOnlyRead(snap: MarketSnapshot): FlowAssessment {
function sourceOnlyRead(snap: MarketSnapshot, tokenizedMarketQuestion: boolean): FlowAssessment {
const publishers = new Set(snap.sources.map(source => source.publisher.trim().toLowerCase()).filter(Boolean));
const latest = snap.sources[0];
if (!latest) return { ...HOLD, summary: `No attributable public headlines were reached for ${snap.ticker}. Check the issuer's original filings and retry later.` };
const date = latest.publishedAt ? new Date(latest.publishedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }) : "date unavailable";
return { ...HOLD, summary: `Collected ${snap.sources.length} public headlines across ${publishers.size} publishers. Most recent listed headline: “${latest.title.slice(0, 170)}” (${latest.publisher}, ${date}). AI synthesis is unavailable, so review the linked items directly. No directional trade assessment was made.` };
const date = latest?.publishedAt ? new Date(latest.publishedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }) : null;
return sourceOnlyAssessment({ ticker: snap.ticker, headlineCount: snap.sources.length, publishers: publishers.size, latestTitle: latest?.title, latestPublisher: latest?.publisher, latestDate: date, tokenizedMarketQuestion, spotSymbol: snap.spot?.symbol, spotPrice: snap.spot?.lastPrice, spotTimestamp: snap.spot?.providerTimestamp, perpSymbol: snap.perp?.symbol });
}

function event(type: "stage" | "result" | "error", payload: unknown) {
Expand Down Expand Up @@ -151,7 +148,7 @@ export async function POST(request: Request) {

const now = Date.now();
const assets = snapshots.map(snap => {
const assessment = assessments[snap.ticker] ?? sourceOnlyRead(snap);
const assessment = assessments[snap.ticker] ?? sourceOnlyRead(snap, tokenizedMarketQuestion);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope tokenized fallback intent to the referenced asset

When AI synthesis is unavailable, this newly passed request-wide flag can make ordinary stock questions produce an rToken answer: the existing matcher treats complete words such as recent, risk, and review as r plus 1–6 letters, so “What is the recent news for NVDA?” is now summarized as an rNVDA-market question. It also applies tokenized intent to every asset in a mixed request such as “Compare rNVDA with TSLA stock,” causing the TSLA fallback to discuss rTSLA. Determine tokenized intent from exact rToken symbols/keywords for each ticker before selecting the fallback.

Useful? React with 👍 / 👎.

let completed: MarketSnapshot["candles"] = [];
try { completed = prepareBacktestCandles(snap.candles, "4H", now); }
catch { /* Malformed candles keep this asset's trading gate closed. */ }
Expand Down
1 change: 1 addition & 0 deletions app/flow/flow.css
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,4 @@
.flow-coverage small{flex-basis:100%;line-height:1.55}
.flow-confidence-note{margin:-8px 0 14px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--amber) 28%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--amber) 7%,transparent);color:var(--muted);font:10px/1.5 var(--font)}
@media(max-width:560px){.flow-depth-badge{margin:7px 0 0}.flow-coverage{gap:8px 12px;padding:12px;border-radius:12px}.flow-coverage span:last-of-type{flex-basis:100%}}
.flow-market-references{display:grid;gap:7px;margin:14px 0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:color-mix(in srgb,var(--panel) 76%,transparent)}.flow-market-references>b{color:var(--mint);font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase}.flow-market-references>span{color:var(--muted);font:10px/1.55 var(--font)}
6 changes: 3 additions & 3 deletions app/ui/agent-flow-desk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type AssetRead = {
confidence: number; confidenceCapped?: boolean; tradeable: boolean; gateReasons: string[];
evidenceBalance?: { supporting: Array<{ text: string; citedUrls: string[] }>; counter: Array<{ text: string; citedUrls: string[] }> };
sources: Array<{ title: string; url: string; publisher: string; publishedAt: string | null; via?: "Public feeds" | "AgentKey" }>;
market: { perpSymbol: string | null; spotSymbol: string | null; lastPrice: number | null; providerTimestamp?: number | null };
market: { perpSymbol: string | null; spotSymbol: string | null; lastPrice: number | null; providerTimestamp?: number | null; perpLastPrice?: number | null; perpProviderTimestamp?: number | null; spotLastPrice?: number | null; spotProviderTimestamp?: number | null };
handoff: { destinations: Destination[]; reason: string } | null;
};
type FlowResult = { researchRunId?: string; question: string; assets: AssetRead[]; tradeable: boolean; handoff?: unknown; trace: StageEvent[]; researchCoverage?: { mode: string; lookbackDays: number; queriesPerTicker: number; collectedItems: number; publishers: number; sourceDomains: number; collectedAt: string; limitations: string[] } };
Expand All @@ -27,7 +27,7 @@ function QuoteFreshness({ market, now }: { market: AssetRead["market"]; now: num
return <div className={`flow-quote ${fresh ? "flow-quote-fresh" : "flow-quote-stale"}`}>
<b>{fresh ? "Within the 2-minute quote window" : known ? "Quote outside the 2-minute window" : "Quote freshness unverified"}</b>
{known ? <span>Provider quote as of <time dateTime={new Date(timestamp).toISOString()}>{new Date(timestamp).toLocaleString([], { dateStyle: "medium", timeStyle: "medium" })}</time> (your local time)</span> : <span>No usable provider timestamp was returned.</span>}
<span>{market.perpSymbol || market.spotSymbol || "Market reference"} · Snapshot from this research run; prices do not update here.</span>
<span>Stock perpetual reference · {market.perpSymbol || "Unavailable"} · Snapshot from this research run; prices do not update here.</span>
</div>;
}

Expand Down Expand Up @@ -176,7 +176,7 @@ export default function AgentFlowDesk({ initialQuestion, evaluatorMode = false }
{error ? <div className="flow-error" role="alert"><b>Research paused</b><span>{error}</span>{error.toLowerCase().includes("sign in") ? <Link href="/login">Sign in ↗</Link> : <button type="button" onClick={() => void runFlow()} disabled={busy}>Try again ↗</button>}</div> : null}

{result ? <section className="flow-results" aria-label="Research findings"><div className="flow-section-head"><div><span className="flow-kicker">RESEARCH / {result.assets.length} {result.assets.length === 1 ? "ISSUER" : "ISSUERS"}</span><h2>One question. Separate decisions.</h2><p>Issuer coverage and token-market conditions are shown separately. Review the source links and quote timestamp before considering any next step.</p></div></div>
<div className="flow-asset-grid">{result.assets.map((asset) => <article key={asset.ticker} className="flow-asset-card"><header><div><span className="flow-kicker">{asset.ticker} / {asset.issuer}</span><h3>{asset.issuer}</h3></div><span className={`flow-verdict ${asset.tradeable ? "flow-verdict-ready" : ""}`}>{asset.tradeable ? "REVIEW AVAILABLE" : "NO TRADE HANDOFF"}</span></header><p>{asset.summary}</p><div className="flow-asset-meta"><span>Read <b>{asset.stance}</b></span><span>Model confidence <b>{Math.round(asset.confidence * 100)}%</b></span>{asset.market.lastPrice !== null ? <span>Quote snapshot <b>${asset.market.lastPrice.toLocaleString()}</b></span> : null}</div><p className="flow-confidence-note" role="note">{asset.confidenceCapped ? "Evidence is limited: the score is capped until two recent, distinct publishers support the assessment. " : ""}Model-reported score, not a calibrated probability.</p><QuoteFreshness market={asset.market} now={quoteNow} />{asset.evidenceBalance && (asset.evidenceBalance.supporting.length > 0 || asset.evidenceBalance.counter.length > 0) ? <section className="flow-evidence-balance" aria-label={`${asset.ticker} evidence balance`}><header><b>Evidence balance</b><span>Source-linked points only</span></header><div className="flow-evidence-columns">{([["Supports this reading", asset.evidenceBalance.supporting], ["Challenges this reading", asset.evidenceBalance.counter]] as const).map(([label, points]) => <section className="flow-evidence-side" key={label}><h4>{label}</h4>{points.length ? <ul>{points.map((point, index) => <li key={index}><p>{point.text}</p><div>{point.citedUrls.map((url) => { const source = asset.sources.find((item) => item.url === url); return source ? <a key={url} href={url} target="_blank" rel="noopener noreferrer">{source.publisher} ↗</a> : null; })}</div></li>)}</ul> : <p className="flow-evidence-empty">No source-linked points were established for this side.</p>}</section>)}</div></section> : null}{asset.gateReasons?.length ? <div className="flow-gates"><b>Decision gates</b><ul>{asset.gateReasons.map((reason, index) => <li key={index}>{reason}</li>)}</ul></div> : null}<details className="flow-sources"><summary>Inspect {asset.sources.length} {asset.sources.length === 1 ? "source" : "sources"}</summary>{asset.sources.length ? <ul>{asset.sources.map((source, index) => <li key={`${source.url}-${index}`}><span>{source.publisher}{source.publishedAt ? ` · ${new Date(source.publishedAt).toLocaleDateString()}` : ""}{source.via ? <em className="flow-source-via"> · {source.via}</em> : null}</span><a href={source.url} target="_blank" rel="noopener noreferrer">{source.title} ↗</a></li>)}</ul> : <p>No source links were returned; the trading gate remains closed.</p>}</details></article>)}</div>
<div className="flow-asset-grid">{result.assets.map((asset) => <article key={asset.ticker} className="flow-asset-card"><header><div><span className="flow-kicker">{asset.ticker} / {asset.issuer}</span><h3>{asset.issuer}</h3></div><span className={`flow-verdict ${asset.tradeable ? "flow-verdict-ready" : ""}`}>{asset.tradeable ? "REVIEW AVAILABLE" : "NO TRADE HANDOFF"}</span></header><p>{asset.summary}</p><div className="flow-asset-meta"><span>Read <b>{asset.stance}</b></span><span>Model confidence <b>{Math.round(asset.confidence * 100)}%</b></span></div><section className="flow-market-references" aria-label={`${asset.ticker} instrument checks`}><b>Instrument checks</b><span>Tokenized spot · {asset.market.spotSymbol ?? `R${asset.ticker}USDT`} · {asset.market.spotLastPrice != null ? `$${asset.market.spotLastPrice.toLocaleString()}` : "No verified quote"}{asset.market.spotProviderTimestamp ? ` · ${new Date(asset.market.spotProviderTimestamp).toLocaleString()}` : " · freshness unverified"}</span><span>Stock perpetual · {asset.market.perpSymbol ?? `${asset.ticker}USDT`} · {asset.market.perpLastPrice != null ? `$${asset.market.perpLastPrice.toLocaleString()}` : "No verified quote"}{asset.market.perpProviderTimestamp ? ` · ${new Date(asset.market.perpProviderTimestamp).toLocaleString()}` : " · freshness unverified"} · derivative reference</span></section><p className="flow-confidence-note" role="note">{asset.confidenceCapped ? "Evidence is limited: the score is capped until two recent, distinct publishers support the assessment. " : ""}Model-reported score, not a calibrated probability.</p><QuoteFreshness market={asset.market} now={quoteNow} />{asset.evidenceBalance && (asset.evidenceBalance.supporting.length > 0 || asset.evidenceBalance.counter.length > 0) ? <section className="flow-evidence-balance" aria-label={`${asset.ticker} evidence balance`}><header><b>Evidence balance</b><span>Source-linked points only</span></header><div className="flow-evidence-columns">{([["Supports this reading", asset.evidenceBalance.supporting], ["Challenges this reading", asset.evidenceBalance.counter]] as const).map(([label, points]) => <section className="flow-evidence-side" key={label}><h4>{label}</h4>{points.length ? <ul>{points.map((point, index) => <li key={index}><p>{point.text}</p><div>{point.citedUrls.map((url) => { const source = asset.sources.find((item) => item.url === url); return source ? <a key={url} href={url} target="_blank" rel="noopener noreferrer">{source.publisher} ↗</a> : null; })}</div></li>)}</ul> : <p className="flow-evidence-empty">No source-linked points were established for this side.</p>}</section>)}</div></section> : null}{asset.gateReasons?.length ? <div className="flow-gates"><b>Decision gates</b><ul>{asset.gateReasons.map((reason, index) => <li key={index}>{reason}</li>)}</ul></div> : null}<details className="flow-sources"><summary>Inspect {asset.sources.length} {asset.sources.length === 1 ? "source" : "sources"}</summary>{asset.sources.length ? <ul>{asset.sources.map((source, index) => <li key={`${source.url}-${index}`}><span>{source.publisher}{source.publishedAt ? ` · ${new Date(source.publishedAt).toLocaleDateString()}` : ""}{source.via ? <em className="flow-source-via"> · {source.via}</em> : null}</span><a href={source.url} target="_blank" rel="noopener noreferrer">{source.title} ↗</a></li>)}</ul> : <p>No source links were returned; the trading gate remains closed.</p>}</details></article>)}</div>
{tradeableAssets.length ? <div className="flow-handoff" ref={handoffRef} tabIndex={-1} role="region" aria-label="Choose a trading review desk"><div className="flow-handoff-intro"><span className="flow-kicker">NEXT / YOUR DECISION</span><h2>Review the market before acting.</h2><p>The research gates found a scenario worth inspecting. Choose paper futures, or review a Bitget tokenized stock spot order in demo or live account mode. You can review both desks. Every order still requires your own approval and risk checks.</p></div><div className="flow-handoff-list">{tradeableAssets.map((asset) => <div className="flow-handoff-asset" key={asset.ticker}><div><b>{asset.ticker}</b><span>{asset.handoff?.reason}</span></div><div className="flow-handoff-actions">{asset.handoff?.destinations.flatMap((destination) => { const href = safeDeskLink(destination); if (!href) return []; if (destination.mode === "paper_futures") return [<Link key="paper" href={href} onClick={() => setMode("pro")}>Review paper futures <span>↗</span></Link>]; const demo = `${href}${href.includes("?") ? "&" : "?"}intent=demo`; const live = `${href}${href.includes("?") ? "&" : "?"}intent=live`; return [<Link key="demo" href={demo} onClick={() => setMode("pro")}>Review Bitget demo spot <span>↗</span></Link>, <Link key="live" href={live} onClick={() => setMode("pro")}>Review Bitget live spot <span>↗</span></Link>]; })}</div></div>)}</div><small>Research can be uncertain. A review link never places a trade or switches your account into live mode.{mode === "mini" ? " Trade reviews open the Pro workspace controls." : ""}</small></div> : <div className="flow-no-handoff"><span className="flow-kicker">RISK GATE / HOLD</span><b>No trading handoff for this read.</b><p>Review the sources and decision gates above. You can return when fresher or stronger evidence is available.</p><Link href="/markets">Explore the market map ↗</Link></div>}
</section> : null}
</div>;
Expand Down
11 changes: 11 additions & 0 deletions lib/agent-flow-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ export type FlowGateInput = {
now: number;
};

/** Evidence-only fallback used when every configured synthesis provider is unavailable. */
export function sourceOnlyAssessment(input: { ticker: string; headlineCount: number; publishers: number; latestTitle?: string | null; latestPublisher?: string | null; latestDate?: string | null; tokenizedMarketQuestion: boolean; spotSymbol?: string | null; spotPrice?: number | null; spotTimestamp?: number | null; perpSymbol?: string | null }): FlowAssessment {
const { ticker } = input;
const summary = input.tokenizedMarketQuestion
? `${input.headlineCount} issuer-level ${ticker} headline${input.headlineCount === 1 ? " was" : "s were"} collected across ${input.publishers} publisher${input.publishers === 1 ? "" : "s"}${input.latestTitle ? `; latest listed item: “${input.latestTitle.slice(0, 120)}”${input.latestPublisher ? ` (${input.latestPublisher}${input.latestDate ? `, ${input.latestDate}` : ""})` : ""}` : ""}. This coverage concerns ${ticker}, not the r${ticker} token market. ${input.spotSymbol && input.spotPrice != null ? `A tokenized spot quote was returned for ${input.spotSymbol} at ${input.spotPrice}; quote freshness ${input.spotTimestamp ? "is shown separately below" : "is unverified"}.` : `No verified r${ticker} tokenized-spot quote was returned.`} ${input.perpSymbol ? `${input.perpSymbol} is a stock-perpetual reference, not the rToken.` : "No stock-perpetual reference was verified."} r${ticker}-specific liquidity, spread, and premium or discount were not measured. Conclusion: not enough evidence to judge whether r${ticker} is worth reviewing as a trading opportunity. A due-diligence review could proceed after verifying token availability, a fresh token quote, liquidity, spread, and premium or discount. AI synthesis is unavailable; no directional assessment was made.`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the fallback summary before returning it

For an rToken fallback with a latest headline, this summary routinely exceeds 800 characters, and the unsliced publisher name can push it beyond the 900-character limit enforced by parseIntelligenceResult in lib/intelligence-ui.ts. In that case a successfully completed run started from the Overview or global companion is rejected as failing interface validation; owner-published replays also unconditionally slice the summary to 700 characters in app/api/research/replays/route.ts, typically cutting off the final AI-unavailable and no-directional-assessment disclaimer. Keep the generated fallback within the strictest downstream bound or preserve the complete structured conclusion through those consumers.

Useful? React with 👍 / 👎.

: input.headlineCount
? `Collected ${input.headlineCount} public headlines across ${input.publishers} publishers${input.latestTitle ? `. Most recent listed headline: “${input.latestTitle.slice(0, 150)}”${input.latestPublisher ? ` (${input.latestPublisher}${input.latestDate ? `, ${input.latestDate}` : ""})` : ""}` : ""}. AI synthesis is unavailable, so review the linked items directly. No directional trade assessment was made.`
: `No attributable public headlines were reached for ${ticker}. Check the issuer's original filings and retry later.`;
return { summary, stance: "unclear", confidence: 0, citedUrls: [], supportingEvidence: [], counterEvidence: [], risks: ["AI synthesis is unavailable; retrieved headlines are discovery references only.", ...(input.tokenizedMarketQuestion ? ["Issuer coverage does not establish rToken liquidity, spread, or premium/discount."] : [])], nextCheck: input.tokenizedMarketQuestion ? "Verify the rToken's live availability, quote freshness, liquidity, spread, and premium or discount from instrument-specific sources." : "Review issuer filings and completed candles." };
}

const aliases: Record<string, string[]> = {
NVDA: ["NVIDIA"], TSLA: ["TESLA"], AAPL: ["APPLE"], AMD: ["ADVANCED MICRO DEVICES"],
MSFT: ["MICROSOFT"], META: ["META PLATFORMS"], AMZN: ["AMAZON"],
Expand Down
Loading
Loading