383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
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)}`;
|
||
}
|