Files
Mutual_Fund/frontend/lib/assistant-api.ts
T

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ApiError, apiFetch, readSSE, type SSESummary } from "@/lib/api";
import type { Page } from "@/lib/admin-api";
/**
* 投顾 Agent(/api/advisor-agent/*)适配层。
*
* 该系列接口的成功码是 0 而不是 200(agent_success),已在 lib/api.ts 统一兼容。
*
* 关于客户上下文:客户不是必填项,前端不做单独的客户选择。
* - POST /chat/stream 的 customer_id 是可选的(AgentChatReq 默认 None),
* 不传时后端会自己从问题文本里认客户,认不出就按「未指定客户」走通用问答;
* - /data-query、/fund-analysis、/generate-talk-script、/rebalance/run
* 请求体里没有 customer_id 字段,后端统一用 _resolve_customer_from_query
* 从 query 文本里解析「客户 #12」或已授权客户的姓名(api/routers/advisor_agent.py)。
* 所以只要用户在问题里写明客户,工具类接口就能正常解析;写不清楚时后端返回 40001,
* 由 advisorErrorHint() 给出可操作的中文提示。
*/
/* ------------------------------------------------------------------ 契约 */
export interface AgentMessage {
role?: string;
content?: string;
[key: string]: unknown;
}
export interface AgentDraft {
draft_id?: string;
id?: string;
title?: string | null;
content?: string | null;
intent?: string | null;
status?: string | null;
customer_id?: number | null;
create_time?: string | null;
update_time?: string | null;
structured_data?: Record<string, unknown> | null;
[key: string]: unknown;
}
/** 与后端 nl2sql/contracts.DataQueryResult 对齐,字段只在展示侧挑选。 */
export interface DataQueryResult {
query_id?: string;
trace_id?: string;
customer_id?: number | null;
columns?: string[];
rows?: Record<string, unknown>[];
row_count?: number;
truncated?: boolean;
summary?: string | null;
answer?: string | null;
markdown?: string | null;
warnings?: string[];
elapsed_ms?: number;
[key: string]: unknown;
}
/** 与后端 agent/advisor_agent/intent/fund_analysis.build_fund_analysis 的返回对齐。 */
export interface FundAnalysisItem {
fund_code?: string | null;
fund_name?: string | null;
risk_level?: string | null;
metrics?: Record<string, number | null>[];
analysis_text?: string | null;
chart_data?: Record<string, unknown> | null;
[key: string]: unknown;
}
export interface FundAnalysisResult extends FundAnalysisItem {
/** 命中多只基金时后端返回 items,单只时返回扁平结构。 */
items?: FundAnalysisItem[];
}
/** 与后端 agent/advisor_agent/intent/talk_script.build_talk_script 的返回对齐。 */
export interface TalkScriptResult {
scene_type?: string;
content?: string;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ 常量映射 */
/** 客户端发送的会话上下文选择,与后端 AdvisorChatReq.scope 对齐。 */
export type ChatScopeMode = "customer" | "advisor";
/** 后端 common_const 的 AGENT_INTENT_* / 兜底值。 */
export type AdvisorIntent =
| "recommend"
| "rebalance"
| "fund_analysis"
| "dialogue-script"
| "data_query"
| "casual_chat"
| "general_question"
| string;
const INTENT_LABELS: Record<string, string> = {
recommend: "产品推荐",
rebalance: "调仓再平衡",
fund_analysis: "基金分析",
"dialogue-script": "沟通话术",
data_query: "客户数据查询",
casual_chat: "闲聊",
general_question: "通用问答",
};
/** 意图英文值 → 中文标签;未知值原样返回,便于排查后端新增意图。 */
export function intentLabel(intent?: string | null): string | null {
if (!intent) return null;
return INTENT_LABELS[intent] ?? intent;
}
/** 话术场景(后端 TALK_SCENE_*)。 */
const SCENE_LABELS: Record<string, string> = {
market_fluctuation: "市场波动安抚",
customer_complaint: "客户投诉",
risk_block_order: "订单被风控拦截",
portfolio_divergence: "组合偏离基准",
};
export function sceneLabel(scene?: string | null): string | null {
if (!scene) return null;
return SCENE_LABELS[scene] ?? scene;
}
/** 草稿状态(后端 DRAFT_STATUS_*)。 */
const DRAFT_STATUS_LABELS: Record<string, string> = {
draft: "草稿",
discarded: "已废弃",
sent: "已发送",
};
export function draftStatusLabel(status?: string | null): string | null {
if (!status) return null;
return DRAFT_STATUS_LABELS[status] ?? status;
}
/* ------------------------------------------------------------------ 文本规则 */
/**
* 后端 /fund-analysis 用 `[A-Za-z]{1,6}\d{3,8}` 抓基金代码,
* 且按 product_code 精确匹配(库里形如 F000001)。
* 纯数字的 000001 抓不到,前端要提前给出可操作的提示,而不是等后端报「请补充基金代码」。
*/
export const FUND_CODE_RE = /[A-Za-z]{1,6}\d{3,8}/g;
export function extractFundCodes(query: string): string[] {
return query.toUpperCase().match(FUND_CODE_RE) ?? [];
}
export function hasFundCode(query: string): boolean {
return extractFundCodes(query).length > 0;
}
/* ------------------------------------------------------------------ 错误文案 */
/** 后端错误码 → 可执行的中文提示(common_const.py)。 */
const CODE_HINTS: Record<number, string> = {
40001: "请在问题里带上客户编号或姓名,例如「客户 #12 最近一个月的持仓明细」。",
40020: "方案适当性校验未通过:包含超出客户风险等级的产品。",
40030: "该客户尚未签约,无法生成调仓草稿。",
40401: "草稿不存在或已废弃,刷新草稿列表看看。",
50001: "AI 服务调用异常,可以稍后重试,或换一种问法。",
};
/**
* 把后端 message 转成「原因 + 下一步」。
* 后端部分能力尚未装配时返回未初始化错误(ERR_CODE_LLM_ERROR),单独给一句更准确的指引。
*/
export function advisorErrorHint(message?: string | null, code?: number | string): string | null {
if (!message) return null;
if (message.includes("核心能力尚未初始化")) {
return "这句话触发的意图后端暂未装配。调仓 / 再平衡请用下方「调仓再平衡」工具;数据查询请用「查客户数据」。";
}
const numeric = typeof code === "string" ? Number(code) : code;
if (typeof numeric === "number" && CODE_HINTS[numeric]) return CODE_HINTS[numeric];
return null;
}
/* ------------------------------------------------------------------ 请求 */
function qs(params: Record<string, string | number | undefined | null>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
export interface DataQueryParams {
query: string;
session_id?: string;
/** 后端限制:max_rows ≤ 10000,page ≥ 1,page_size ∈ [1, 10000]。 */
max_rows?: number;
page?: number;
page_size?: number;
sort_by?: string;
sort_order?: "asc" | "desc";
}
export interface DraftListParams {
customer_id?: number;
status?: "draft" | "discarded";
page?: number;
page_size?: number;
}
export const assistantApi = {
/** GET /session/{session_id}/history —— 读取本人短期 Agent 会话记录。 */
sessionHistory: (sessionId: string) =>
apiFetch<AgentMessage[]>(`/advisor-agent/session/${encodeURIComponent(sessionId)}/history`),
/**
* POST /chat/stream —— 客户完全可选。
* customer_id 缺省时后端会自己从问题文本里认客户,认不出就走通用问答,不会报错。
*/
chatOnce(
body: {
query: string;
session_id?: string;
scope?: ChatScopeMode;
customer_id?: number | null;
},
signal?: AbortSignal
): Promise<SSESummary> {
return readSSE("/advisor-agent/chat/stream", body, signal);
},
/**
* POST /data-query —— 自然语言查客户数据(非流式)。
* 返回里 columns / rows / row_count / truncated / warnings 是真实结果集,
* 不要只取 answer 文本,否则表格数据白拿。
* 客户由后端从 query 文本解析,解析不到返回 40001。
*/
dataQuery: (body: DataQueryParams) =>
apiFetch<DataQueryResult>("/advisor-agent/data-query", {
method: "POST",
body: JSON.stringify(body),
}),
/** GET /draft/list —— 不传 customer_id 时返回当前投顾名下全部草稿。 */
drafts: (params: DraftListParams = {}) =>
apiFetch<Page<AgentDraft> & { items?: AgentDraft[] }>(
`/advisor-agent/draft/list${qs({
customer_id: params.customer_id,
status: params.status,
page: params.page,
page_size: params.page_size,
})}`
),
/** GET /draft/{draft_id} */
draft: (draftId: string) =>
apiFetch<AgentDraft>(`/advisor-agent/draft/${encodeURIComponent(draftId)}`),
/** PUT /draft/{draft_id}/save —— 后端会按客户风险等级做适当性校验并可能拒绝。 */
saveDraft: (
draftId: string,
body: { title?: string; content?: string; structured_data?: Record<string, unknown> }
) =>
apiFetch<AgentDraft>(`/advisor-agent/draft/${encodeURIComponent(draftId)}/save`, {
method: "PUT",
body: JSON.stringify(body),
}),
/** POST /draft/{draft_id}/operate —— 后端目前仅支持 operation=discard。 */
operateDraft: (draftId: string, operation: "discard") =>
apiFetch<AgentDraft>(`/advisor-agent/draft/${encodeURIComponent(draftId)}/operate`, {
method: "POST",
body: JSON.stringify({ operation }),
}),
/**
* POST /rebalance/run —— 异步受理:立即返回 {accepted, status:"queued"},
* 草稿由后台任务生成,需要通过 waitForNewDraft() 轮询草稿列表才能拿到。
* 请求体只有 query;客户未签约会返回 40030。
*/
runRebalance: (query: string) =>
apiFetch<{ accepted?: boolean; status?: string }>("/advisor-agent/rebalance/run", {
method: "POST",
body: JSON.stringify({ query }),
}),
/**
* POST /fund-analysis —— 请求体只有 query。
* 后端要求 query 里含 `字母+数字` 形式的基金代码(形如 F000001),
* 纯数字代码抓不到,调用前用 hasFundCode() 先校验。
*/
fundAnalysis: (query: string) =>
apiFetch<FundAnalysisResult>("/advisor-agent/fund-analysis", {
method: "POST",
body: JSON.stringify({ query }),
}),
/** POST /generate-talk-script —— 请求体只有 query。 */
generateTalkScript: (query: string) =>
apiFetch<TalkScriptResult>("/advisor-agent/generate-talk-script", {
method: "POST",
body: JSON.stringify({ query }),
}),
};
/* ------------------------------------------------------------------ 轮询 */
export interface WaitForDraftOptions {
/** 轮询开始前已有的草稿 ID,用来判断「新」。 */
excludeDraftIds?: readonly string[];
/** 草稿出现在哪个意图下,用于收紧匹配(可选)。 */
intent?: string;
timeoutMs?: number;
intervalMs?: number;
signal?: AbortSignal;
}
function draftIdOf(draft: AgentDraft): string {
return draft.draft_id ?? draft.id ?? "";
}
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}
if (signal?.aborted) return onAbort();
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* 轮询草稿列表,等待 /rebalance/run 的后台任务落库。
*
* 后端是「先受理、后生成」,前端如果只弹一句「稍后生成」就结束,
* 用户实际上看不到结果,只能手动点刷新。这里最多轮询 timeoutMs,
* 命中新草稿就返回;超时返回 null,由调用方提示「可手动刷新」。
*/
export async function waitForNewDraft(options: WaitForDraftOptions = {}): Promise<AgentDraft | null> {
const {
excludeDraftIds = [],
intent,
timeoutMs = 30_000,
intervalMs = 3_000,
signal,
} = options;
const known = new Set(excludeDraftIds);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(intervalMs, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
let page: Page<AgentDraft> & { items?: AgentDraft[] };
try {
page = await assistantApi.drafts({ page: 1, page_size: 20 });
} catch (error) {
/* 单次失败不放弃,继续轮询到超时;401 直接抛出交给上层跳登录 */
if (error instanceof ApiError && error.status === 401) throw error;
continue;
}
const fresh = (page.items ?? []).find((draft) => {
const id = draftIdOf(draft);
if (!id || known.has(id)) return false;
if (intent && draft.intent && draft.intent !== intent) return false;
return true;
});
if (fresh) return fresh;
}
return null;
}
/** 生成本地会话 ID(后端 session_id 为空时自动创建,这里只是前端约定的稳定键)。 */
export function newSessionId(): string {
return `advisor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}