feat(agent): add internal finance evidence decisioner
This commit is contained in:
parent
695d001f9f
commit
4c4f8989ca
4 changed files with 428 additions and 1 deletions
68
packages/core/src/agent/research/finance-decisioner.test.ts
Normal file
68
packages/core/src/agent/research/finance-decisioner.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildInternalFinanceGuidance, decideFinanceEvidencePlan } from "./finance-decisioner.js";
|
||||
|
||||
describe("decideFinanceEvidencePlan", () => {
|
||||
it("returns undefined for non-finance prompts", () => {
|
||||
const result = decideFinanceEvidencePlan({
|
||||
prompt: "Write a TypeScript utility to parse CSV files.",
|
||||
tools: ["read", "write", "exec"],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers data_only for secondary market non-event tasks", () => {
|
||||
const result = decideFinanceEvidencePlan({
|
||||
prompt: "Analyze AAPL valuation based on 5-year financial statements.",
|
||||
tools: ["data", "web_search", "web_fetch"],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.plan).toBe("data_only");
|
||||
expect(result?.marketRoute).toBe("secondary");
|
||||
expect(result?.reasons).toContain("secondary_market_task");
|
||||
});
|
||||
|
||||
it("prefers hybrid for event-driven secondary tasks", () => {
|
||||
const result = decideFinanceEvidencePlan({
|
||||
prompt: "Why did AAPL drop after latest earnings and guidance update?",
|
||||
tools: ["data", "web_search", "web_fetch"],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.plan).toBe("hybrid");
|
||||
expect(result?.reasons).toContain("event_driven");
|
||||
expect(result?.reasons).toContain("causal_explanation_needed");
|
||||
});
|
||||
|
||||
it("prefers web_first for primary market tasks without ticker", () => {
|
||||
const result = decideFinanceEvidencePlan({
|
||||
prompt: "Review this pre-IPO issuance structure and lock-up risks.",
|
||||
tools: ["data", "web_search", "web_fetch"],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.marketRoute).toBe("primary_no_ticker");
|
||||
expect(result?.plan).toBe("web_first");
|
||||
});
|
||||
|
||||
it("degrades when web tools are unavailable for event-driven tasks", () => {
|
||||
const result = decideFinanceEvidencePlan({
|
||||
prompt: "Analyze latest earnings surprise drivers for TSLA stock.",
|
||||
tools: ["data"],
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.reasons).toContain("web_tools_unavailable");
|
||||
expect(result?.confidencePenalty).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInternalFinanceGuidance", () => {
|
||||
it("formats internal guidance text", () => {
|
||||
const decision = decideFinanceEvidencePlan({
|
||||
prompt: "Analyze latest AAPL earnings impact on valuation.",
|
||||
tools: ["data", "web_search"],
|
||||
});
|
||||
expect(decision).toBeDefined();
|
||||
const guidance = buildInternalFinanceGuidance(decision!);
|
||||
expect(guidance).toContain("Internal Finance Research Guidance");
|
||||
expect(guidance).toContain("Preferred evidence plan:");
|
||||
expect(guidance).toContain("Do not expose technical labels");
|
||||
});
|
||||
});
|
||||
304
packages/core/src/agent/research/finance-decisioner.ts
Normal file
304
packages/core/src/agent/research/finance-decisioner.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* Finance evidence decisioner.
|
||||
*
|
||||
* Produces an internal research plan for finance tasks:
|
||||
* - data_only
|
||||
* - hybrid (data + web validation)
|
||||
* - web_first
|
||||
*
|
||||
* The output is intended for internal orchestration only.
|
||||
*/
|
||||
|
||||
export type FinanceEvidencePlan = "data_only" | "hybrid" | "web_first";
|
||||
|
||||
export type FinanceMarketRoute = "secondary" | "primary_with_ticker" | "primary_no_ticker";
|
||||
|
||||
export type FinanceConfidencePenalty = "low" | "medium" | "high";
|
||||
|
||||
export interface FinanceDecisionInput {
|
||||
prompt: string;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export interface FinanceDecision {
|
||||
plan: FinanceEvidencePlan;
|
||||
marketRoute: FinanceMarketRoute;
|
||||
confidencePenalty: FinanceConfidencePenalty;
|
||||
reasons: string[];
|
||||
score: Record<FinanceEvidencePlan, number>;
|
||||
}
|
||||
|
||||
const FINANCE_KEYWORDS = [
|
||||
"stock",
|
||||
"stocks",
|
||||
"equity",
|
||||
"equities",
|
||||
"valuation",
|
||||
"financial",
|
||||
"finance",
|
||||
"earnings",
|
||||
"revenue",
|
||||
"eps",
|
||||
"cash flow",
|
||||
"balance sheet",
|
||||
"income statement",
|
||||
"pe ratio",
|
||||
"market cap",
|
||||
"ipo",
|
||||
"pre-ipo",
|
||||
"listing",
|
||||
"ticker",
|
||||
"一级市场",
|
||||
"二级市场",
|
||||
"财报",
|
||||
"股票",
|
||||
"估值",
|
||||
"市值",
|
||||
"募资",
|
||||
"锁定期",
|
||||
"稀释",
|
||||
];
|
||||
|
||||
const PRIMARY_MARKET_KEYWORDS = [
|
||||
"ipo",
|
||||
"pre-ipo",
|
||||
"prospectus",
|
||||
"s-1",
|
||||
"f-1",
|
||||
"roadshow",
|
||||
"listing",
|
||||
"follow-on",
|
||||
"new issuance",
|
||||
"lock-up",
|
||||
"dilution",
|
||||
"一级市场",
|
||||
"募资",
|
||||
"锁定期",
|
||||
"稀释",
|
||||
];
|
||||
|
||||
const EVENT_DRIVEN_KEYWORDS = [
|
||||
"latest",
|
||||
"recent",
|
||||
"today",
|
||||
"yesterday",
|
||||
"breaking",
|
||||
"earnings call",
|
||||
"guidance",
|
||||
"surprise",
|
||||
"selloff",
|
||||
"policy",
|
||||
"fed",
|
||||
"fomc",
|
||||
"news",
|
||||
"headline",
|
||||
"突发",
|
||||
"最新",
|
||||
"消息",
|
||||
"政策",
|
||||
"财报后",
|
||||
];
|
||||
|
||||
const CAUSAL_KEYWORDS = [
|
||||
"why",
|
||||
"reason",
|
||||
"driver",
|
||||
"impact",
|
||||
"because",
|
||||
"attribution",
|
||||
"explain",
|
||||
"原因",
|
||||
"驱动",
|
||||
"影响",
|
||||
"为什么",
|
||||
];
|
||||
|
||||
const TIME_SENSITIVE_KEYWORDS = [
|
||||
"latest",
|
||||
"today",
|
||||
"this week",
|
||||
"this month",
|
||||
"current",
|
||||
"now",
|
||||
"最新",
|
||||
"当前",
|
||||
"近期",
|
||||
];
|
||||
|
||||
const COMMON_UPPERCASE_NON_TICKERS = new Set([
|
||||
"IPO",
|
||||
"SEC",
|
||||
"USD",
|
||||
"CNY",
|
||||
"HKD",
|
||||
"GDP",
|
||||
"CPI",
|
||||
"PPI",
|
||||
"FED",
|
||||
"FOMC",
|
||||
"EPS",
|
||||
"FCF",
|
||||
"PE",
|
||||
"TTM",
|
||||
"DCF",
|
||||
]);
|
||||
|
||||
function includesAny(text: string, keywords: string[]): boolean {
|
||||
return keywords.some((keyword) => text.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeTools(tools: string[]): Set<string> {
|
||||
return new Set(tools.map((tool) => tool.toLowerCase()));
|
||||
}
|
||||
|
||||
function hasTickerSignal(prompt: string): boolean {
|
||||
const explicit = /(?:\$|ticker\s*[:=]\s*)([A-Za-z]{1,6})/g;
|
||||
if (explicit.test(prompt)) return true;
|
||||
|
||||
const upperWords = prompt.match(/\b[A-Z]{1,6}\b/g) ?? [];
|
||||
const candidates = upperWords.filter((word) => !COMMON_UPPERCASE_NON_TICKERS.has(word));
|
||||
return candidates.length > 0;
|
||||
}
|
||||
|
||||
function isFinanceTask(prompt: string): boolean {
|
||||
const normalized = prompt.toLowerCase();
|
||||
return includesAny(normalized, FINANCE_KEYWORDS);
|
||||
}
|
||||
|
||||
function resolveMarketRoute(prompt: string): FinanceMarketRoute {
|
||||
const normalized = prompt.toLowerCase();
|
||||
const primary = includesAny(normalized, PRIMARY_MARKET_KEYWORDS);
|
||||
if (!primary) return "secondary";
|
||||
return hasTickerSignal(prompt) ? "primary_with_ticker" : "primary_no_ticker";
|
||||
}
|
||||
|
||||
function choosePlan(score: Record<FinanceEvidencePlan, number>): FinanceEvidencePlan {
|
||||
const order: FinanceEvidencePlan[] = ["data_only", "hybrid", "web_first"];
|
||||
let best: FinanceEvidencePlan = order[0];
|
||||
for (const plan of order) {
|
||||
if (score[plan] > score[best]) best = plan;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function resolveConfidencePenalty(params: {
|
||||
plan: FinanceEvidencePlan;
|
||||
hasData: boolean;
|
||||
hasWeb: boolean;
|
||||
route: FinanceMarketRoute;
|
||||
eventDriven: boolean;
|
||||
timeSensitive: boolean;
|
||||
}): FinanceConfidencePenalty {
|
||||
const { plan, hasData, hasWeb, route, eventDriven, timeSensitive } = params;
|
||||
|
||||
if (!hasData && !hasWeb) return "high";
|
||||
if ((plan === "hybrid" || plan === "web_first") && !hasWeb) return "high";
|
||||
if (plan === "data_only" && (eventDriven || timeSensitive) && !hasWeb) return "high";
|
||||
if (route === "primary_no_ticker") return "medium";
|
||||
if (plan === "data_only" && (eventDriven || timeSensitive)) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
export function decideFinanceEvidencePlan(input: FinanceDecisionInput): FinanceDecision | undefined {
|
||||
const { prompt } = input;
|
||||
if (!isFinanceTask(prompt)) return undefined;
|
||||
|
||||
const normalized = prompt.toLowerCase();
|
||||
const toolSet = normalizeTools(input.tools);
|
||||
const hasData = toolSet.has("data");
|
||||
const hasWebSearch = toolSet.has("web_search");
|
||||
const hasWebFetch = toolSet.has("web_fetch");
|
||||
const hasWeb = hasWebSearch || hasWebFetch;
|
||||
|
||||
const route = resolveMarketRoute(prompt);
|
||||
const eventDriven = includesAny(normalized, EVENT_DRIVEN_KEYWORDS);
|
||||
const causal = includesAny(normalized, CAUSAL_KEYWORDS);
|
||||
const timeSensitive = includesAny(normalized, TIME_SENSITIVE_KEYWORDS);
|
||||
|
||||
const score: Record<FinanceEvidencePlan, number> = {
|
||||
data_only: hasData ? 1.0 : -3.0,
|
||||
hybrid: hasData && hasWeb ? 1.0 : -2.0,
|
||||
web_first: hasWeb ? 0.6 : -3.0,
|
||||
};
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (route === "secondary") {
|
||||
score.data_only += 0.7;
|
||||
score.hybrid += 0.4;
|
||||
reasons.push("secondary_market_task");
|
||||
} else if (route === "primary_with_ticker") {
|
||||
score.hybrid += 0.9;
|
||||
score.web_first += 0.3;
|
||||
score.data_only -= 0.2;
|
||||
reasons.push("primary_market_with_ticker");
|
||||
} else {
|
||||
score.web_first += 1.3;
|
||||
score.hybrid += 0.7;
|
||||
score.data_only -= 1.0;
|
||||
reasons.push("primary_market_without_ticker");
|
||||
}
|
||||
|
||||
if (eventDriven) {
|
||||
score.hybrid += 0.9;
|
||||
score.web_first += 0.4;
|
||||
score.data_only -= 0.5;
|
||||
reasons.push("event_driven");
|
||||
}
|
||||
|
||||
if (timeSensitive) {
|
||||
score.hybrid += 0.6;
|
||||
score.web_first += 0.3;
|
||||
score.data_only -= 0.4;
|
||||
reasons.push("time_sensitive");
|
||||
}
|
||||
|
||||
if (causal) {
|
||||
score.hybrid += 0.4;
|
||||
score.web_first += 0.2;
|
||||
score.data_only -= 0.2;
|
||||
reasons.push("causal_explanation_needed");
|
||||
}
|
||||
|
||||
if (!hasWeb) {
|
||||
score.hybrid -= 2.0;
|
||||
score.web_first -= 3.0;
|
||||
reasons.push("web_tools_unavailable");
|
||||
}
|
||||
if (!hasData) {
|
||||
score.data_only -= 2.5;
|
||||
score.hybrid -= 1.5;
|
||||
score.web_first += 0.5;
|
||||
reasons.push("data_tool_unavailable");
|
||||
}
|
||||
|
||||
const plan = choosePlan(score);
|
||||
const confidencePenalty = resolveConfidencePenalty({
|
||||
plan,
|
||||
hasData,
|
||||
hasWeb,
|
||||
route,
|
||||
eventDriven,
|
||||
timeSensitive,
|
||||
});
|
||||
|
||||
return {
|
||||
plan,
|
||||
marketRoute: route,
|
||||
confidencePenalty,
|
||||
reasons,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInternalFinanceGuidance(decision: FinanceDecision): string {
|
||||
return [
|
||||
"## Internal Finance Research Guidance",
|
||||
"This section is internal orchestration guidance. Do not expose technical labels directly to the user unless they explicitly request methodology details.",
|
||||
`Preferred evidence plan: ${decision.plan}`,
|
||||
`Market route: ${decision.marketRoute}`,
|
||||
`Confidence penalty if evidence gaps remain: ${decision.confidencePenalty}`,
|
||||
`Decision factors: ${decision.reasons.join(", ") || "none"}`,
|
||||
"Execution policy: start with the preferred plan, then escalate evidence collection if signals conflict or causality remains unresolved.",
|
||||
].join("\n");
|
||||
}
|
||||
|
|
@ -35,8 +35,12 @@ import {
|
|||
import {
|
||||
buildSystemPrompt as buildStructuredSystemPrompt,
|
||||
collectRuntimeInfo,
|
||||
type SystemPromptMode,
|
||||
} from "./system-prompt/index.js";
|
||||
import {
|
||||
buildInternalFinanceGuidance,
|
||||
decideFinanceEvidencePlan,
|
||||
type FinanceDecision,
|
||||
} from "./research/finance-decisioner.js";
|
||||
import type { AuthProfileFailureReason } from "./auth-profiles/index.js";
|
||||
import {
|
||||
sanitizeToolCallInputs,
|
||||
|
|
@ -425,6 +429,7 @@ export class Agent {
|
|||
): Promise<AgentRunResult> {
|
||||
await this.ensureInitialized();
|
||||
this.refreshAuthState();
|
||||
this.applyFinanceResearchGuidance(prompt);
|
||||
this.output.state.lastAssistantText = "";
|
||||
this.currentUserDisplayPrompt = options?.displayPrompt;
|
||||
|
||||
|
|
@ -968,6 +973,13 @@ export class Agent {
|
|||
* Shared by constructor (via buildFullSystemPrompt) and reloadSystemPrompt.
|
||||
*/
|
||||
private rebuildSystemPrompt(toolNames: string[]): string | undefined {
|
||||
return this.rebuildSystemPromptWithExtra(toolNames);
|
||||
}
|
||||
|
||||
private rebuildSystemPromptWithExtra(
|
||||
toolNames: string[],
|
||||
extraSystemPrompt?: string | undefined,
|
||||
): string | undefined {
|
||||
const profile = this.profile?.getProfile();
|
||||
if (!profile) return undefined;
|
||||
|
||||
|
|
@ -993,6 +1005,40 @@ export class Agent {
|
|||
tools: toolNames,
|
||||
skillsPrompt,
|
||||
runtime,
|
||||
extraSystemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
private applyFinanceResearchGuidance(prompt: string): void {
|
||||
const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name);
|
||||
const decision = decideFinanceEvidencePlan({ prompt, tools: toolNames });
|
||||
|
||||
const guidance = decision ? buildInternalFinanceGuidance(decision) : undefined;
|
||||
const systemPrompt = this.rebuildSystemPromptWithExtra(toolNames, guidance);
|
||||
if (systemPrompt) {
|
||||
this.agent.setSystemPrompt(systemPrompt);
|
||||
this.session.setSystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
this.saveFinanceDecisionMeta(decision);
|
||||
}
|
||||
|
||||
private saveFinanceDecisionMeta(decision: FinanceDecision | undefined): void {
|
||||
const currentMeta = this.session.getMeta() ?? {};
|
||||
if (!decision) {
|
||||
// Keep previously saved decisions for non-finance turns.
|
||||
return;
|
||||
}
|
||||
this.session.saveMeta({
|
||||
...currentMeta,
|
||||
researchDecision: {
|
||||
domain: "finance",
|
||||
plan: decision.plan,
|
||||
marketRoute: decision.marketRoute,
|
||||
confidencePenalty: decision.confidencePenalty,
|
||||
reasons: decision.reasons,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ export type SessionMeta = {
|
|||
reasoningMode?: string;
|
||||
/** Context window token 数 */
|
||||
contextWindowTokens?: number;
|
||||
/** Internal finance evidence decision from the latest run */
|
||||
researchDecision?: {
|
||||
domain: "finance";
|
||||
plan: "data_only" | "hybrid" | "web_first";
|
||||
marketRoute: "secondary" | "primary_with_ticker" | "primary_no_ticker";
|
||||
confidencePenalty: "low" | "medium" | "high";
|
||||
reasons: string[];
|
||||
timestamp: number;
|
||||
} | undefined;
|
||||
};
|
||||
|
||||
export type SessionEntry =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue