diff --git a/docs/asset-ai-question-log.md b/docs/asset-ai-question-log.md new file mode 100644 index 0000000..edfa371 --- /dev/null +++ b/docs/asset-ai-question-log.md @@ -0,0 +1,23 @@ +# AI 问数全量记录 + +在部署包含本次代码的服务之前,执行增量建表(只创建审计表,不修改业务数据): + +```sh +node --import tsx scripts/migrate-ai-questions.ts # 只查看 SQL,不连接数据库 +node --import tsx scripts/migrate-ai-questions.ts --apply # 使用部署环境 DB 配置执行 +``` + +表:`bi_asset_ai_questions`。所有已登录、通过参数校验的 `/ask` 请求都先 INSERT,再执行查询;不按问题去重。 +未登录、空问题、非法请求体不作为用户提问保存。输入中的凭据会脱敏。没有自动清理策略;旧的需求缺口表仍按原有 90 天规则维护。 + +- `event_type`:question 首次提问、followup 连续追问、action 下钻操作、feedback 未解决反馈。 +- `business_types`:JSON 数组,使用既有计划分类(inventory 库存、operating 运营、pending 待交车、abnormal 异动、flow 交还替换等);综合问题可以有多个分类,无法完成理解时为空数组。 +- `outcome`:processing 处理中、success 成功、empty 无匹配、partial 部分回答、clarification 待澄清、help 使用说明、unsupported 暂不支持、failed 失败、cancelled 取消、rejected 拒绝执行、feedback 用户反馈。 +- `reason`:失败/拒绝/缺口原因代码,例如 busy、session_expired、generation、unsatisfied。 +- `actor_id` 是实际操作者;`user_id`、`login_name`、`user_name` 是当前数据权限用户;模拟视角另有 `is_preview` 与 `preview_session_id`。 +- `question` 保存接受的完整问题(最多 2000 字符,敏感凭据脱敏),`action_json` 保存下钻参数。会话、上级结果、当前结果、公司和权限级别分别关联。 +- `answer` 保存回答摘要、提示和子分析摘要,不复制车辆明细数据;另存引擎、开始/结束时间、毫秒耗时。 + +初始写入失败返回 503,不继续执行无记录的查询。结束更新失败重试一次;原始问题仍在数据库,回答提示“处理结果暂未保存”,服务只输出记录 ID,不输出问题或数据库错误。进程突然终止或结束更新失败的记录保持 processing,可通过 started_at 识别长期未完成,不能将其算作成功。 + +模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。 diff --git a/migrations/20260928_asset_ai_questions.sql b/migrations/20260928_asset_ai_questions.sql new file mode 100644 index 0000000..5bf9d89 --- /dev/null +++ b/migrations/20260928_asset_ai_questions.sql @@ -0,0 +1,31 @@ +-- Explicit additive migration. No business tables are changed and no historical records are purged. +CREATE TABLE IF NOT EXISTS bi_asset_ai_questions ( + id CHAR(36) PRIMARY KEY, + actor_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + login_name VARCHAR(255) NOT NULL, + user_name VARCHAR(255) NOT NULL, + permission_level VARCHAR(24) NOT NULL, + is_preview TINYINT(1) NOT NULL DEFAULT 0, + preview_session_id VARCHAR(64) NULL, + session_id VARCHAR(64) NULL, + base_result_id VARCHAR(64) NULL, + result_id VARCHAR(64) NULL, + subject VARCHAR(150) NULL, + event_type VARCHAR(24) NOT NULL, + question TEXT NULL, + action_json JSON NULL, + outcome VARCHAR(24) NOT NULL, + reason VARCHAR(64) NULL, + business_types JSON NULL, + engine VARCHAR(24) NULL, + answer MEDIUMTEXT NULL, + started_at DATETIME(3) NOT NULL, + finished_at DATETIME(3) NULL, + duration_ms BIGINT UNSIGNED NULL, + KEY user_time (user_id, started_at), + KEY actor_time (actor_id, started_at), + KEY result_time (outcome, started_at), + KEY session_time (session_id, started_at), + KEY event_time (event_type, started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/migrate-ai-questions.ts b/scripts/migrate-ai-questions.ts new file mode 100644 index 0000000..f12bff3 --- /dev/null +++ b/scripts/migrate-ai-questions.ts @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises'; +// Import the pool only after an explicit apply flag, to avoid accidental connections in review mode. +const sql = await readFile(new URL('../migrations/20260928_asset_ai_questions.sql', import.meta.url), 'utf8'); +if (!process.argv.includes('--apply')) { + console.log(sql); + console.log('Review only. To create this audit table in the configured DB, pass --apply.'); +} else { + const { default: pool } = await import('../src/server/db.js'); + try { + await pool.query(sql); + const [rows] = await pool.query('SELECT COUNT(*) AS columns_count FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=?', ['bi_asset_ai_questions']); + console.log('AI question audit table ready.', rows); + } finally { + await pool.end(); + } +} diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 9d1783e..c7ecf27 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -34,7 +34,9 @@ import { useAuth } from "../../../auth/useAuth"; import { ThinkingState } from "./ThinkingState"; import { CompoundResultView } from "./CompoundResultView"; import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; +import { useMobileLayout } from "../useMobileLayout"; import "./assistant.css"; +import "./mobile-assistant.css"; type Turn = { id: number; @@ -55,24 +57,28 @@ const PROMPTS = [ { icon: BarChart3, title: "现在多少车在运营、多少车在库存?", + mobileTitle: "运营与库存", text: BUSINESS_QUESTIONS[0], detail: "总资产 · 运营 · 库存 · 运营率", }, { icon: Truck, title: "库存集中在哪些车型和区域?", + mobileTitle: "库存分布", text: BUSINESS_QUESTIONS[1], detail: "库存分布 · 继续查看车辆", }, { icon: History, title: "最近7天交车、还车分别多少?", + mobileTitle: "近 7 天交还车", text: BUSINESS_QUESTIONS[2], detail: "按日趋势 · 业务明细", }, { icon: ListIcon, title: "有哪些车辆需要跟进?", + mobileTitle: "待跟进车辆", text: BUSINESS_QUESTIONS[3], detail: "异动详情 · 预计完成时间", }, @@ -94,6 +100,8 @@ export default function AssetAssistant({ const dataScopeLabel = !user ? "正在确认数据范围" : user.permissionLevel === "full" ? "全量数据" : user.permissionLevel === "department" ? "部门数据" : "本人数据"; + const mobile = useMobileLayout(); + const [contextExpanded, setContextExpanded] = useState(false); const page = useRef(null); useLayoutEffect(() => { const viewport = window.visualViewport; @@ -557,7 +565,7 @@ export default function AssetAssistant({