Compare commits

..
10 Commits
Author SHA1 Message Date
Cursor Agentandhaijd 71cb593d63 fix: 收紧跑题识别,避免股市等闲聊写入槽位
Co-authored-by: haijd <haijd@163.com>
2026-09-20 05:52:45 +00:00
Cursor Agentandhaijd 625a6a3a9d feat: 客服对话支持寒暄回复与跑题拒答
问候不再写入槽位;明显无关问题直接拒绝,知识库未命中时明确说明无法回答,并继续合理追问。

Co-authored-by: haijd <haijd@163.com>
2026-09-20 05:52:22 +00:00
Cursor Agentandhaijd cd67211043 fix: 同轮可同时写入路线与场景关键词
Co-authored-by: haijd <haijd@163.com>
2026-09-20 01:48:24 +00:00
Cursor Agentandhaijd 85ee9020c6 docs: 按 freshliance.com 首页重做样例知识
改为公司定位与 TagPlus-T / BlueTag T10 / GAEA G1000 选型说明,替换原先 Fresh Tag 1 单品样例。

Co-authored-by: haijd <haijd@163.com>
2026-09-20 01:47:53 +00:00
Cursor Agentandhaijd bb8b3742ac fix: 冷链/药品关键词写入场景槽位
Co-authored-by: haijd <haijd@163.com>
2026-09-20 01:45:23 +00:00
Cursor Agentandhaijd 3111142984 docs: 增加 Fresh Tag 1 官网样例知识与冷链判定
基于 freshliance.com 产品页整理产品资料与方案适用说明,并接入判定树。

Co-authored-by: haijd <haijd@163.com>
2026-09-20 01:44:50 +00:00
Cursor Agentandhaijd b7bd830da7 feat: 用 Ragflow 搭建鲜境方案客服
咨询台接 Ragflow 检索与示例知识库,支持追问、判定、引用和留资。

Co-authored-by: haijd <haijd@163.com>
2026-09-20 01:08:11 +00:00
Cursor Agentandhaijd 9c6afcb61a feat: 可配置方案引擎通用原型(Mock 检索)
需求未定稿时可先跑通追问/判定/出方案;槽位与判定树外置 YAML,后续可接 Ragflow。

Co-authored-by: haijd <haijd@163.com>
2026-09-18 09:12:18 +00:00
Cursor Agentandhaijd 93b0867eb3 docs: 新增 Ragflow 搭建实施方案
覆盖架构切分、知识库、方案引擎状态机、双场景、Eval 与实施顺序。

Co-authored-by: haijd <haijd@163.com>
2026-09-18 08:04:27 +00:00
Cursor Agentandhaijd 84846bcc8a docs: 补充不赶工期时以 Ragflow 为主的选型
Co-authored-by: haijd <haijd@163.com>
2026-09-18 08:02:48 +00:00
39 changed files with 2360 additions and 27 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
.venv/
venv/
.env
data/
*.pyc
.DS_Store
+39 -27
View File
@@ -1,32 +1,44 @@
# 鲜境物联 · AI 智能客服|技术路线建议
# 鲜境物联 · AI 智能客服
综合建议书、验收标准、**委托开发合同**与**附件二进度表**后的实施路线结论。
用 **Ragflow** 做知识库检索,用本仓库的方案引擎做「追问 → 判定 → 出方案 → 留资」。
## 结论
线上咨询台:[鲜境智能客服](http://127.0.0.1:3847/)
Ragflow 控制台:[Ragflow](http://127.0.0.1:18080/)
**推荐:开源自托管二开 + 核心业务定制。不建议纯自研,不建议纯公有云 SaaS。**
示例产品资料在 `knowledge/corpus/`,型号是占位,等甲方资料替换。判定规则在 `knowledge/decision_tree.yaml`,由业务方定对错。
| 路线 | 结论 |
## 本机已经怎么跑
1. Ragflow v0.27.1 用 Docker 起在 `/opt/ragflow/docker`(内存已调低,适配约 16GB 以下的机器)。
2. `scripts/bootstrap_ragflow.py` 注册管理员、建 `products` / `solutions` / `faqs`,灌入语料并写 `.env`。
3. 方案引擎读 `.env` 里的 API Key 与数据集 ID,检索走 `POST /api/v1/retrieval`。
## 自己重来一遍
```bash
# 1. 起 Ragflow(首次会拉镜像)
cd /opt/ragflow/docker
sudo docker compose -f docker-compose.yml up -d
# 2. 若容器互相连不上(日志里 MySQL timeout),补网桥转发后重启 ragflow
bash scripts/fix_docker_forward.sh
cd /opt/ragflow/docker && sudo docker restart docker-ragflow-cpu-1
cd /workspace
source .venv/bin/activate
pip install -r engine/requirements.txt
python scripts/bootstrap_ragflow.py
# 3. 咨询台
uvicorn engine.app.main:app --host 0.0.0.0 --port 3847
```
没有 Docker 时可以把 `.env` 里的 `RETRIEVAL_BACKEND` 改成 `mock`,只用本地示例片段演示对话,不代表知识库已接通。
## 目录
| 路径 | 作用 |
|------|------|
| 纯自研 | 不推荐首期(撞上 **7 周 / ¥5 万** 硬顶) |
| 纯 SaaS(扣子 / 阿里云客服等) | 不推荐(数据自主可控冲突) |
| **Dify 自托管二开 + 方案判定定制** | **推荐** |
**二开首选:Dify**;检索吃力时加 **Ragflow**;人工席位 **Chatwoot**(或企微/飞书)。
合同已锁:实施 **¥50,000**、订阅 **¥30,000/年**、周期 **7 周**、线上场景 **只做一个高频渠道**。详见完整建议书。
## 文档
| 文件 | 说明 |
|------|------|
| [docs/技术方案建议书.md](./docs/技术方案建议书.md) | 决策依据、平台对比、架构、7 周映射、风险 |
| [uploads/](./uploads/) | 原始合同、进度表、建议书、验收标准 |
## 建议立刻确认
1. 首期唯一渠道:网站还是 Facebook?
2. 方案判定树的业务拍板人
3. 模型:智谱 API 是否可上生产 / 是否必须私有化
4. 场景二(录音辅助)是否与场景一同期验收
5. 部署:甲方机房还是乙方 VPC
| `engine/` | 咨询 API 与页面 |
| `knowledge/` | 槽位、判定树、灌库语料 |
| `scripts/bootstrap_ragflow.py` | 对接 Ragflow 建库 |
| `docs/` | 选型与实施说明 |
+241
View File
@@ -0,0 +1,241 @@
# 鲜境 AI 客服 · Ragflow 搭建方案
> 前提:底座选 **Ragflow(自托管,Apache-2.0)**;「意图 → 追问 → 方案生成 → 转人工」用**自研薄方案引擎**补齐,不把业务逻辑只画在厂商画布里。
---
## 1. 总体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 渠道层 │
│ 网站插件 / FB(首期只做一个) │ 销售端:录音上传或实时转写入口 │
└───────────────┬─────────────────────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ 方案引擎 API(自研) │ │ ASR 服务(场景二) │
│ · 会话状态 / 追问槽位 │◄───│ 云 ASR 或私有 Whisper │
│ · 意图分类 │ └─────────────────────────────┘
│ · 方案判定树 + 产品组合 │
│ · 置信度 / 敏感词 → 转人工 │
│ · 方案文本模板 │
└──────────┬───────────────┘
│ 检索 API(query → chunks + citation)
▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ Ragflow(自托管) │ │ 人工席 │
│ · Dataset 知识库 │ │ Chatwoot / 企微 / 飞书 │
│ · 解析·切块·Embedding │ │ 低置信度工单 + 对话记录推送 │
│ · 召回·重排·引用 │ └─────────────────────────────┘
│ ·(可选)内置 Chat 仅调试用 │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 模型层 │
│ LLM:智谱等(可切私有化) │
│ Embedding:与库同环境 │
└──────────────────────────┘
```
**职责切分(务必守住):**
| 组件 | 负责 | 不负责 |
|------|------|--------|
| Ragflow | 资料入库、检索、引用片段 | 业务判定树、渠道 UI、人工工单主流程 |
| 方案引擎 | 多轮状态、追问、匹配产品组合、出方案、转人工策略 | 重做一套向量库 |
| 渠道/ASR/人工席 | 触达与交接 | 知识解析 |
---
## 2. 环境与部署
### 2.1 建议拓扑(首期)
- 单机或一小集群:`Docker Compose` 部署 Ragflow(含其依赖的 ES/Infinity、MySQL、MinIO、Redis 等,以官方 compose 为准)。
- 方案引擎:独立服务(建议 **Python FastAPI** 或 **Node/Hono**),与 Ragflow 同 VPC。
- 对象存储:产品原文 PDF/Word 放 MinIO 或客户 OSS;Ragflow 只存解析结果与向量。
- 配置:模型 Key、Ragflow API Key 进环境变量,不进仓库。
### 2.2 模型
| 用途 | 建议 | 说明 |
|------|------|------|
| 对话 / 方案生成 | 智谱 GLM 等国产 API | 合同要求数据可控;可按成本换模型 |
| Embedding | Ragflow 推荐且已验证的中英模型 | **一经选定勿随意换**(换则全量重建) |
| ASR | 先云端;涉密再私有化 Whisper | 场景二 |
### 2.3 许可与交付物
- 开源清单写明:Ragflow = Apache-2.0;方案引擎为定制代码(归甲方);列出传递依赖。
- 保留 Ragflow 版权声明即可,无 Dify 类「去 Logo / 多租户」附加条款问题。
---
## 3. 知识库怎么建(Ragflow 内)
### 3.1 Dataset 划分(建议)
不要做成一个「大杂烩库」:
| Dataset | 内容 | 用途 |
|---------|------|------|
| `products` | 型号、参数、适用工况、禁忌 | 产品组合匹配时的硬事实 |
| `solutions` | 历史方案、两大类技术选型说明 | 方案话术与结构 |
| `faqs` | 高频询盘 Q&A | 降重复劳动 |
| `policies` | 敏感词、不可承诺条款(可选) | 护栏 |
### 3.2 入库规范(W1 和甲方定)
- 每份文档元数据:`产品线`、`技术大类(A/B)`、`型号`、`语言`、`版本日期`。
- 优先:**结构化表(型号×参数)+ 方案范文**;纯长 PDF 靠解析,但关键参数尽量表结构化。
- 切块:参数表用「按行/按型号」策略;方案用「按章节」;入库后做 **Recall 测试**(人工出 30 个真实问法看片段对不对)。
### 3.3 检索调用方式
方案引擎只调 Ragflow **检索 API**(或官方 SDK),拿到:
- `content` / `score` / `document` / 引用定位
- 再交给 LLM **仅基于引用**生成;无命中则追问或转人工,禁止空编参数。
---
## 4. 方案引擎怎么搭(自研核心)
### 4.1 会话状态机(最小闭环)
```
新会话
→ 意图识别(咨询/比价/售后/无关…)
→ 若「方案咨询」:进入技术大类判定
→ 追问槽位未满:生成下一个问题
→ 槽位已满:检索 + 判定树 → 产品组合
→ 渲染方案模板 → 返回用户
→ 低置信 / 敏感 / 用户要人工 → 转人工 + 留资
```
### 4.2 追问槽位(示例,W1 由业务专家定稿)
按鲜境「两大类技术」分别设必问项,例如:
- 应用场景 / 工况环境
- 处理对象或指标范围
- 产能或规模
- 现场约束(空间、已有设备、预算区间)
- 交付区域/语言(若接海外询盘)
槽位存会话表(Postgres/SQLite 首期均可),不要只靠模型「记忆」。
### 4.3 判定树 + 产品组合
- 专家规则优先:`IF 大类=A AND 工况=X AND 指标∈[…] THEN 推荐型号组合`
- RAG 补充:方案说明、话术、注意事项从 `solutions`/`products` 检索填空
- 输出结构固定:`推荐组合` + `理由(带引用)` + `需人工确认项` + `下一步留资`
### 4.4 接口草图
```
POST /chat # 渠道统一入口:session_id + message
POST /sessions/:id/handoff
POST /ingest/notify # 可选:资料更新后重建/增量索引钩子
GET /admin/evals # 评测任务与结果
```
渠道插件、FB Webhook、销售录音页都只打 `/chat`。
---
## 5. 两场景落地
### 场景一:线上 24h 咨询(合同:只做一个高频渠道)
1. 选定网站 **或** FB。
2. 嵌入聊天窗 / Webhook → 方案引擎。
3. UI 展示:回复 +(可选)引用折叠;「转人工」「留电话」。
4. 转人工:创建 Chatwoot 会话或推企微,附带槽位摘要与最近检索命中。
### 场景二:销售辅助
1. 上传录音或对接会议转写 → ASR 文本。
2. 文本进入同一状态机(可跳过寒暄意图,直接抽槽位)。
3. 输出「可发给客户的方案草稿」+ 内部注意点;由销售确认后发送。
---
## 6. 人工接管与后台
| 能力 | 做法 |
|------|------|
| 转人工 | 引擎规则触发 → Chatwoot/企微;超时未接则留资入库 |
| 对话追溯 | 引擎写 `messages` 表;可同步一份到人工席 |
| 知识维护 | 业务在 Ragflow 控制台更新 Dataset;重大变更跑 Recall 抽样 |
| 效果看板 | 首期:导出日志 + 简单页(任务完成率、转人工率、Eval 分数);勿一上来做大屏 |
---
## 7. AI Eval(对着附件三)
| 指标 | 做法 |
|------|------|
| 意图识别 ≥85% | 200 条标注;引擎意图模块离线测 |
| 方案匹配 ≥80% | 专家盲测「引擎方案 vs 专家方案」 |
| 任务完成 ≥70% | 日志:未转人工且用户达方案/留资 |
| 幻觉 ≤5% | 抽检:断言中参数是否出现在引用 chunk |
| 敏感泄露 =0% | 正则+词表扫描回复 |
评测集与判定树一样:**外置文件**,不锁在 Ragflow 里。
---
## 8. 推荐实施顺序(可不绑死 7 周)
| 步 | 做什么 | 完成标准 |
|----|--------|----------|
| 1 | Compose 拉起 Ragflow;接通 LLM/Embedding | 控制台可聊「基于空库」 |
| 2 | 灌入一小撮真实产品+方案;Recall 测试 | 业务说「找得对」 |
| 3 | 定稿判定树与槽位;引擎跑通 API | Postman 多轮能出方案结构 |
| 4 | 护栏:无检索不编参数;转人工;留资 | 故意问库外问题会转人工 |
| 5 | 接一个渠道 + 基础聊天 UI | 真实访客可走通 |
| 6 | 场景二 ASR 联调 | 一段销售录音能出草稿 |
| 7 | Eval 全量 + UAT | 附件三阈值或书面差距说明 |
| 8 | 生产部署、备份、监控、开源清单 | 可交接 |
---
## 9. 仓库/工程建议(乙方交付形态)
```
/infra # docker-compose、备份脚本
/engine # 方案引擎(状态机、判定、调 Ragflow)
/web-widget # 网站聊天插件(若渠道是网站)
/eval # 评测集、脚本
/knowledge # 元数据说明、判定树 YAML、产品表(原文大文件可外链)
/docs # 部署说明、开源清单、运维手册
```
定制成果在 `engine` / `knowledge` / `eval`;Ragflow 用官方镜像,**尽量不 Fork 魔改核心**(升级痛苦)。二开以「配置 + 外围服务」为主。
---
## 10. 风险与戒条
1. 不要用 Ragflow 默认 Chat 直接当生产客服终态——缺槽位与判定,难稳过方案匹配。
2. 不要把判定树只写在 Prompt 里——要结构化规则 + 检索填空。
3. 换 Embedding / 大改切块 = 重建索引 + 重做 Recall。
4. 首期渠道只做一个;双渠道等订阅期再加。
5. 双栈再挂 Dify:仅当自研编排人力不够时临时用,默认不必。
---
## 11. 和合同交付的对应
| 合同/验收项 | 落在哪 |
|-------------|--------|
| 知识库可检索、可维护 | Ragflow Dataset + 权限 |
| 智能问答多轮 | 方案引擎状态机 |
| 方案自动生成 | 判定树 + 检索 + 模板 |
| 人工接管 | 引擎策略 + Chatwoot/企微 |
| 对话记录/看板 | 引擎库表 + 轻量看板 |
| AI Eval 阈值 | `/eval` + 专家盲测 |
| 开源清单 | Ragflow Apache-2.0 + 依赖列表 |
+18
View File
@@ -0,0 +1,18 @@
# 需求未定稿时:先搭通用原型的步骤
## 要不要先搭原型?
**要。** 做成「配置驱动的通用骨架」,不要等需求 100% 精确再动手。
- 先验证:追问 → 判定 → 检索引用 → 出方案草稿
- 业务变了:主要改 `knowledge/*.yaml`
- 后台/渠道:闭环跑通后再补
## 具体步骤
1. **拉起本原型**(不必先装 Ragflow)
2. **和业务改槽位/判定树 YAML**(边聊边改,`POST /api/admin/reload-config`)
3. **灌真实小样本到 Ragflow**,切 `RETRIEVAL_BACKEND=ragflow`
4. **再补**转人工、单渠道、Eval、薄后台
不要颠倒:先做大后台或双渠道。
+29
View File
@@ -152,6 +152,35 @@ Dify(意图分类 → 追问槽位 → 调 Ragflow → 方案生成 → 转人
- 只求本单快验收、单租户可留 Logo → Dify 仍可行,但把判定树/评测集/原始知识**外置**,当保险。
- 不要假设「二开完还能廉价换家」——能换的是资产,不是画布。
#### 若不纯赶工期,怎么选?
时间让位之后,排序应变成:**许可干净 > 检索/幻觉可过验收 > 对话编排体验 > 上线速度**。
**推荐默认改成:Ragflow 为主底座 + 自研薄「方案引擎」(判定树 / 追问槽位 / 转人工策略)。**
| 理由 | 说明 |
|------|------|
| 许可 | 纯 Apache,乙方后续多客户复用、招标合规更省心 |
| 验收 | 鲜境是资料驱动的「卖方案」,命中率/幻觉更吃检索,Ragflow 更对症 |
| 锁定 | 首期就站在以后更可能留下的底座上,避免 Dify→Ragflow 重配画布 |
| 编排缺口 | 用自研 API/状态机补「意图→追问→出方案」,业务逻辑落在你们代码与配置里,不绑死厂商画布 |
**这时还要不要 Dify?**
- **一般不必再引入 Dify**(双栈运维、两套权限/日志,长期总拥有成本更高)。
- 仅当方案引擎自研人力明显不足、又强依赖可视化编排时,可短期用 Dify 只做编排、检索仍走 Ragflow——但要接受多一套依赖。
- Flowise 同理:许可尚可,却更容易把逻辑锁在画布上;时间充裕时不如 **Ragflow + 自研编排** 干净。
**时间充裕时的分期(示意,非合同附件二):**
1. 外置资产:资料仓、产品结构表、判定树、Eval 集
2. Ragflow 灌库 + 召回/幻觉打穿附件三阈值
3. 自研对话状态(槽位追问、低置信转人工、方案模板)
4. 单渠道接入 + 场景二 ASR
5. 再考虑是否要可视化编排器(多数情况不再需要)
**一句话:不赶时间 → 选 Ragflow 路线更安全、更好;Dify 的优势主要是快,不是长期更优。**
---
### 为何首选 Dify(而不是 Flowise)
+1
View File
@@ -0,0 +1 @@
# Package
+1
View File
@@ -0,0 +1 @@
# Package marker
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
import yaml
from .settings import settings
def _load_yaml(name: str) -> dict[str, Any]:
path = settings.knowledge_dir / name
with path.open(encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"{name} must be a mapping")
return data
class BusinessConfig:
def __init__(self) -> None:
self.reload()
def reload(self) -> None:
self.slots_doc = _load_yaml("slots.yaml")
self.tree_doc = _load_yaml("decision_tree.yaml")
self.chunks_doc = _load_yaml("sample_chunks.yaml")
@property
def slots(self) -> list[dict[str, Any]]:
return list(self.slots_doc.get("slots") or [])
@property
def handoff_phrases(self) -> list[str]:
return list(self.slots_doc.get("handoff_phrases") or [])
@property
def closing_prompt(self) -> str:
return str(self.slots_doc.get("closing_prompt") or "正在生成方案…")
@property
def rules(self) -> list[dict[str, Any]]:
return sorted(self.tree_doc.get("rules") or [], key=lambda r: r.get("order", 100))
@property
def handoff_confidence_below(self) -> float:
return float(self.tree_doc.get("handoff_confidence_below", 0.55))
@property
def chunks(self) -> list[dict[str, Any]]:
return list(self.chunks_doc.get("chunks") or [])
business_config = BusinessConfig()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Any
def _match_condition(cond: dict[str, Any], slots: dict[str, str]) -> bool:
ctype = cond.get("type")
if ctype == "always":
return True
slot = str(cond.get("slot") or "")
value = str(cond.get("value") or "")
actual = (slots.get(slot) or "").strip()
if ctype == "slot_equals":
return actual == value
if ctype == "slot_contains":
return value.lower() in actual.lower()
return False
def evaluate_tree(rules: list[dict[str, Any]], slots: dict[str, str]) -> dict[str, Any]:
for rule in rules:
conditions = rule.get("when_all") or []
if all(_match_condition(c, slots) for c in conditions):
then = dict(rule.get("then") or {})
return {
"rule_id": rule.get("id"),
"description": rule.get("description"),
**then,
}
return {
"rule_id": "empty",
"product_bundle": ["待专家确认"],
"tech_line": "待判定",
"summary": "未匹配到规则",
"confidence": 0.0,
"handoff": True,
}
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import re
# 寒暄:不写槽、先回问候再追问
_GREETING_RE = re.compile(
r"^\s*(hi|hello|hey|hola|yo|你好|您好|哈喽|嗨|早上好|上午好|中午好|下午好|晚上好|早|在吗|在不在)[\s!!.。~~??]*$",
re.I,
)
# 跳过/暂无类有效回答
_SKIP_RE = re.compile(r"暂无|没有|无|不清楚|不确定|随便|都可以|未知|n/?a|none|no\b", re.I)
# 鲜境业务相关词:冷链温控 / 官网产品线
_DOMAIN_RE = re.compile(
r"冷链|冷库|仓储|仓库|运输|配送|保温|冷冻|冷藏|温控|温度|湿度|"
r"药品|疫苗|生鲜|实验室|产线|户外|监测|告警|报警|"
r"TagPlus|BlueTag|GAEA|Fresh\s*Tag|USB|PDF|蓝牙|网关|记录仪|"
r"不开箱|行程|温区|WiFi|4G|LAN|API|方案|产品|报价|选型|"
r"实时|读数|报告|传感器|物联网|物联|预算|网络|对接|系统|次数|可重复",
re.I,
)
# 明显跑题话题
_OFFTOPIC_RE = re.compile(
r"天气|气温|下雨|新闻|股票|股市|股指|大盘|基金|彩票|足球|篮球|电影|电视剧|游戏|"
r"笑话|段子|星座|运势|做饭|菜谱|旅游|机票|酒店|恋爱|八卦|"
r"政治|选举|战争|加密货币|比特币|chatgpt|写诗|作文|数学题|"
r"编程作业|leetcode|今天吃什么|炒股|买股",
re.I,
)
_QUESTION_RE = re.compile(r"[??]|什么是|怎么|如何|能否|可以吗|多少|哪[个些]|是否|介绍一下|有没有")
def is_greeting(text: str) -> bool:
return bool(_GREETING_RE.match((text or "").strip()))
def is_skip_answer(text: str) -> bool:
t = (text or "").strip()
if not t:
return False
if _SKIP_RE.fullmatch(t):
return True
return t in {"没有要求", "无要求", "没什么", "先不确定"}
def is_domain_related(text: str) -> bool:
return bool(_DOMAIN_RE.search(text or ""))
def looks_like_question(text: str) -> bool:
return bool(_QUESTION_RE.search(text or ""))
def has_offtopic_topic(text: str) -> bool:
return bool(_OFFTOPIC_RE.search(text or ""))
def looks_off_topic(text: str) -> bool:
"""明显与冷链方案无关的输入;寒暄单独处理,不算跑题。"""
t = (text or "").strip()
if not t or is_greeting(t) or is_skip_answer(t):
return False
if is_domain_related(t):
return False
if _OFFTOPIC_RE.search(t):
return True
# 纯英文闲聊 / 极短无意义且不像在答槽
if re.fullmatch(r"[a-zA-Z\s!.?~]{1,40}", t) and not is_domain_related(t):
lower = t.lower()
if any(w in lower for w in ("hello", "hi", "hey", "thanks", "bye", "ok", "test")):
return False # greeting/ack 另判
if looks_like_question(t):
return True
# 带疑问且无业务词 → 跑题
if len(t) >= 4 and not is_domain_related(t) and looks_like_question(t):
return True
if len(t) >= 10 and not is_domain_related(t):
return True
return False
def should_write_slot(text: str) -> bool:
"""是否允许本轮用户话写入槽位。"""
t = (text or "").strip()
if not t or is_greeting(t):
return False
if looks_off_topic(t):
return False
return True
def greeting_reply(next_prompt: str | None) -> str:
base = "你好!我是鲜境方案客服,可以帮您梳理冷链温控场景并匹配官网产品组合。"
if next_prompt:
return f"{base}\n\n{next_prompt}"
return f"{base}\n\n您可以直接描述场景,例如:「冷库仓储,要实时告警」。"
def refuse_reply(reason: str = "off_topic", next_prompt: str | None = None) -> str:
if reason == "kb_miss":
msg = (
"抱歉,我无法回答这个问题。"
"当前知识库里没有足够相关依据,请换一个与鲜境冷链温控产品/场景相关的问题,"
"或描述您的应用现场,我再帮您追问选型。"
)
else:
msg = (
"抱歉,我无法回答这类问题。"
"我主要协助鲜境物联的冷链温控方案咨询(运输记录、蓝牙读数、仓储网关等)。"
)
if next_prompt:
return f"{msg}\n\n我们继续:{next_prompt}"
return msg
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .settings import ROOT
DB_PATH = ROOT / "data" / "leads.sqlite"
def _conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
name TEXT,
phone TEXT,
note TEXT,
slots_json TEXT,
scheme_json TEXT,
created_at TEXT
)
"""
)
return conn
def add_lead(
*,
session_id: str,
name: str,
phone: str,
note: str,
slots_json: str,
scheme_json: str,
) -> int:
now = datetime.now(timezone.utc).isoformat()
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO leads (session_id, name, phone, note, slots_json, scheme_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(session_id, name, phone, note, slots_json, scheme_json, now),
)
return int(cur.lastrowid)
def list_leads(limit: int = 50) -> list[dict]:
with _conn() as conn:
rows = conn.execute(
"SELECT id, session_id, name, phone, note, created_at FROM leads ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import json
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .config_loader import business_config
from .leads import add_lead, list_leads
from .ragflow_client import RagflowClient, RagflowError
from .scheme_engine import create_session, handle_message
from .session_store import store
from .settings import ROOT, settings
STATIC_DIR = Path(__file__).resolve().parent / "static"
CORPUS_DIR = ROOT / "knowledge" / "corpus"
app = FastAPI(title=settings.app_name, version="0.2.0")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
class ChatRequest(BaseModel):
message: str = Field(..., min_length=1)
session_id: str | None = None
class SessionResponse(BaseModel):
session_id: str
class HandoffRequest(BaseModel):
session_id: str
name: str = Field(..., min_length=1, max_length=40)
phone: str = Field(..., min_length=5, max_length=30)
note: str = ""
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/health")
def health() -> dict:
rag = RagflowClient().health()
return {
"ok": True,
"app": settings.app_name,
"retrieval_backend": settings.retrieval_backend,
"slots": len(business_config.slots),
"rules": len(business_config.rules),
"ragflow": rag,
"ragflow_web": settings.ragflow_web_url,
"dataset_ids_configured": bool(settings.ragflow_dataset_ids.strip()),
}
@app.get("/api/config/slots")
def get_slots() -> dict:
return business_config.slots_doc
@app.get("/api/config/decision-tree")
def get_tree() -> dict:
return business_config.tree_doc
@app.post("/api/admin/reload-config")
def reload_config() -> dict:
business_config.reload()
return {"reloaded": True, "slots": len(business_config.slots), "rules": len(business_config.rules)}
@app.get("/api/knowledge/files")
def knowledge_files() -> dict:
files = []
if CORPUS_DIR.exists():
for path in sorted(CORPUS_DIR.glob("*.md")):
files.append({"name": path.name, "bytes": path.stat().st_size})
return {"files": files}
@app.get("/api/ragflow/status")
def ragflow_status() -> dict:
client = RagflowClient()
health = client.health()
datasets: list[dict] = []
error = ""
if health.get("ok") and settings.ragflow_api_key:
try:
for item in client.list_datasets():
datasets.append(
{
"id": item.get("id"),
"name": item.get("name"),
"document_count": item.get("document_count"),
"chunk_count": item.get("chunk_count"),
}
)
except Exception as exc:
error = str(exc)
return {
"health": health,
"web": settings.ragflow_web_url,
"api_key_set": bool(settings.ragflow_api_key),
"dataset_ids": [x for x in settings.ragflow_dataset_ids.split(",") if x.strip()],
"datasets": datasets,
"error": error,
}
@app.get("/api/leads")
def api_leads() -> dict:
return {"leads": list_leads()}
@app.post("/api/sessions", response_model=SessionResponse)
def api_create_session() -> SessionResponse:
session = create_session()
return SessionResponse(session_id=session.id)
@app.post("/api/chat")
def api_chat(body: ChatRequest) -> dict:
session_id = body.session_id
if not session_id:
session_id = create_session().id
try:
return handle_message(session_id, body.message)
except KeyError:
raise HTTPException(status_code=404, detail="session_not_found") from None
@app.post("/api/handoff")
def api_handoff(body: HandoffRequest) -> dict:
session = store.get(body.session_id)
slots = session.slots if session else {}
scheme = session.last_scheme if session else None
lead_id = add_lead(
session_id=body.session_id,
name=body.name.strip(),
phone=body.phone.strip(),
note=body.note.strip(),
slots_json=json.dumps(slots, ensure_ascii=False),
scheme_json=json.dumps(scheme or {}, ensure_ascii=False),
)
if session:
session.phase = "handoff"
return {"ok": True, "lead_id": lead_id}
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
import httpx
try:
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome.PublicKey import RSA
except ImportError:
from Crypto.Cipher import PKCS1_v1_5
from Crypto.PublicKey import RSA
from .settings import ROOT, settings
PUBLIC_PEM = ROOT / "infra" / "ragflow-public.pem"
class RagflowError(RuntimeError):
pass
def encrypt_password(password: str) -> str:
"""Match Ragflow crypt(): RSA-encrypt base64(password)."""
rsa_key = RSA.import_key(PUBLIC_PEM.read_text())
cipher = PKCS1_v1_5.new(rsa_key)
password_b64 = base64.b64encode(password.encode("utf-8"))
encrypted = cipher.encrypt(password_b64)
return base64.b64encode(encrypted).decode("utf-8")
class RagflowClient:
def __init__(self, base_url: str | None = None, api_key: str | None = None) -> None:
self.base_url = (base_url or settings.ragflow_base_url).rstrip("/")
self.api_key = api_key if api_key is not None else settings.ragflow_api_key
def _headers(self, token: str | None = None) -> dict[str, str]:
key = token or self.api_key
if not key:
return {}
if key.lower().startswith("bearer "):
return {"Authorization": key}
return {"Authorization": f"Bearer {key}"}
def health(self) -> dict[str, Any]:
url = f"{self.base_url}/api/v1/system/healthz"
try:
with httpx.Client(timeout=8.0) as client:
resp = client.get(url)
ok = resp.status_code < 500
return {"ok": ok, "status_code": resp.status_code, "url": url}
except Exception as exc:
return {"ok": False, "error": str(exc), "url": url}
def register(self, email: str, password: str, nickname: str = "鲜境管理员") -> str:
payload = {
"nickname": nickname,
"email": email,
"password": encrypt_password(password),
}
with httpx.Client(timeout=30.0) as client:
resp = client.post(f"{self.base_url}/api/v1/users", json=payload)
if resp.status_code >= 400:
raise RagflowError(f"注册失败 {resp.status_code}: {resp.text[:400]}")
auth = resp.headers.get("Authorization") or ""
body = resp.json() if resp.content else {}
if body.get("code") not in (0, None) and body.get("code") != 0:
# already registered or validation error
message = str(body.get("message") or body)
if "already" in message.lower() or "已经" in message:
return self.login(email, password)
raise RagflowError(message)
if not auth:
data = body.get("data") or {}
auth = str(data.get("id") or "")
if not auth:
raise RagflowError(f"注册成功但没有登录凭证: {body}")
return auth
def login(self, email: str, password: str) -> str:
payload = {"email": email, "password": encrypt_password(password)}
with httpx.Client(timeout=30.0) as client:
resp = client.post(f"{self.base_url}/api/v1/auth/login", json=payload)
if resp.status_code >= 400:
raise RagflowError(f"登录失败 {resp.status_code}: {resp.text[:400]}")
auth = resp.headers.get("Authorization") or ""
if not auth:
raise RagflowError("登录没有返回 Authorization")
return auth
def ensure_api_token(self, session_auth: str) -> str:
headers = {"Authorization": session_auth}
with httpx.Client(timeout=30.0) as client:
listed = client.get(f"{self.base_url}/api/v1/system/tokens", headers=headers)
if listed.status_code < 400:
data = (listed.json() or {}).get("data") or []
if isinstance(data, list) and data:
token = data[0].get("token")
if token:
return token
created = client.post(f"{self.base_url}/api/v1/system/tokens", headers=headers)
if created.status_code >= 400:
raise RagflowError(f"创建 API Token 失败: {created.text[:400]}")
body = created.json()
token = (body.get("data") or {}).get("token")
if not token:
raise RagflowError(f"Token 响应异常: {body}")
return token
def list_datasets(self) -> list[dict[str, Any]]:
with httpx.Client(timeout=20.0) as client:
resp = client.get(f"{self.base_url}/api/v1/datasets", headers=self._headers(), params={"page": 1, "page_size": 50})
resp.raise_for_status()
data = (resp.json() or {}).get("data") or []
if isinstance(data, dict):
data = data.get("datasets") or data.get("kbs") or []
return list(data)
def create_dataset(self, name: str, description: str) -> dict[str, Any]:
payload = {
"name": name,
"description": description,
"chunk_method": "naive",
"permission": "me",
"embedding_model": "BAAI/bge-small-zh-v1.5@Builtin",
"parser_config": {
"graphrag": {"use_graphrag": False},
"raptor": {"use_raptor": False},
},
}
with httpx.Client(timeout=30.0) as client:
resp = client.post(f"{self.base_url}/api/v1/datasets", headers=self._headers(), json=payload)
if resp.status_code >= 400:
raise RagflowError(resp.text[:400])
body = resp.json()
if body.get("code") not in (0, None):
raise RagflowError(str(body.get("message") or body))
return body.get("data") or {}
def update_dataset(self, dataset_id: str, payload: dict[str, Any]) -> dict[str, Any]:
with httpx.Client(timeout=30.0) as client:
resp = client.put(
f"{self.base_url}/api/v1/datasets/{dataset_id}",
headers=self._headers(),
json=payload,
)
if resp.status_code >= 400:
raise RagflowError(resp.text[:400])
body = resp.json()
if body.get("code") not in (0, None):
raise RagflowError(str(body.get("message") or body))
return body.get("data") or {}
def upload_document(self, dataset_id: str, path: Path) -> list[dict[str, Any]]:
with path.open("rb") as fh:
files = {"file": (path.name, fh, "text/markdown")}
with httpx.Client(timeout=60.0) as client:
resp = client.post(
f"{self.base_url}/api/v1/datasets/{dataset_id}/documents",
headers=self._headers(),
files=files,
)
if resp.status_code >= 400:
raise RagflowError(resp.text[:500])
body = resp.json()
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return data
def parse_documents(self, dataset_id: str, document_ids: list[str]) -> None:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{self.base_url}/api/v1/datasets/{dataset_id}/documents/parse",
headers=self._headers(),
json={"document_ids": document_ids},
)
if resp.status_code >= 400:
raise RagflowError(resp.text[:400])
def list_documents(self, dataset_id: str) -> list[dict[str, Any]]:
with httpx.Client(timeout=20.0) as client:
resp = client.get(
f"{self.base_url}/api/v1/datasets/{dataset_id}/documents",
headers=self._headers(),
params={"page": 1, "page_size": 50},
)
resp.raise_for_status()
data = (resp.json() or {}).get("data") or []
if isinstance(data, dict):
data = data.get("docs") or data.get("documents") or []
return list(data)
def retrieve(self, question: str, dataset_ids: list[str], page_size: int = 4) -> list[dict[str, Any]]:
if not self.api_key:
raise RagflowError("未配置 RAGFLOW_API_KEY")
if not dataset_ids:
raise RagflowError("未配置 RAGFLOW_DATASET_IDS")
payload = {
"question": question,
"dataset_ids": dataset_ids,
"page": 1,
"page_size": page_size,
"similarity_threshold": 0.15,
"vector_similarity_weight": 0.3,
}
with httpx.Client(timeout=30.0) as client:
resp = client.post(f"{self.base_url}/api/v1/retrieval", headers=self._headers(), json=payload)
if resp.status_code >= 400:
raise RagflowError(f"检索失败 {resp.status_code}: {resp.text[:300]}")
body = resp.json()
if body.get("code") not in (0, None):
raise RagflowError(str(body.get("message") or body))
data = body.get("data") or {}
chunks = data.get("chunks") if isinstance(data, dict) else data
return list(chunks or [])
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import re
from typing import Any
from .config_loader import business_config
from .ragflow_client import RagflowClient, RagflowError
from .settings import settings
class RetrievalResult:
def __init__(self, chunks: list[dict[str, Any]], backend: str, warning: str = ""):
self.chunks = chunks
self.backend = backend
self.warning = warning
def retrieve_for_scheme(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
backend = (settings.retrieval_backend or "ragflow").lower()
if backend == "mock":
return _retrieve_mock(slots, bundle)
try:
return _retrieve_ragflow(slots, bundle)
except RagflowError as exc:
return RetrievalResult([], "ragflow", warning=str(exc))
def retrieve_question(question: str, *, page_size: int = 3, min_score: float = 0.22) -> RetrievalResult:
"""自由问答检索;分数过低视为知识库未命中。"""
backend = (settings.retrieval_backend or "ragflow").lower()
q = (question or "").strip()
if not q:
return RetrievalResult([], backend or "none", warning="empty_query")
if backend == "mock":
return _retrieve_mock_question(q, min_score=min_score)
try:
client = RagflowClient()
dataset_ids = [item.strip() for item in settings.ragflow_dataset_ids.split(",") if item.strip()]
raw = client.retrieve(q, dataset_ids, page_size=page_size)
chunks = []
for index, item in enumerate(raw[:page_size]):
if not isinstance(item, dict):
continue
score = item.get("similarity")
try:
score_f = float(score) if score is not None else 0.0
except (TypeError, ValueError):
score_f = 0.0
if score_f < min_score:
continue
chunks.append(
{
"id": item.get("id") or f"rf-q-{index}",
"title": item.get("document_keyword")
or item.get("document_name")
or item.get("docnm_kwd")
or "知识片段",
"content": item.get("content") or item.get("content_with_weight") or "",
"score": score_f,
}
)
warning = "" if chunks else "知识库未召回到足够相关的内容"
return RetrievalResult(chunks, "ragflow", warning=warning)
except RagflowError as exc:
return RetrievalResult([], "ragflow", warning=str(exc))
def _retrieve_mock_question(question: str, *, min_score: float = 0.22) -> RetrievalResult:
picked: list[dict[str, Any]] = []
for chunk in business_config.chunks:
blob = f"{chunk.get('title', '')} {chunk.get('content', '')} {' '.join(chunk.get('tags') or [])}"
hits = sum(1 for token in re_split_tokens(question) if token and token in blob)
if hits:
score = min(0.9, 0.2 + 0.15 * hits)
if score >= min_score:
item = dict(chunk)
item["score"] = score
picked.append(item)
if len(picked) >= 3:
break
warning = "" if picked else "知识库未召回到足够相关的内容"
return RetrievalResult(picked, "mock", warning=warning)
def re_split_tokens(text: str) -> list[str]:
return [t for t in re.split(r"[\s,,。;;??!!/|]+", text) if len(t) >= 2]
def _retrieve_mock(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
chunks = business_config.chunks
picked: list[dict[str, Any]] = []
bundle_set = set(bundle)
for chunk in chunks:
if chunk.get("product") in bundle_set:
picked.append(chunk)
blob = " ".join(slots.values())
for chunk in chunks:
if chunk in picked:
continue
tags = chunk.get("tags") or []
if any(str(tag) in blob for tag in tags):
picked.append(chunk)
if len(picked) >= 4:
break
if not picked:
picked = chunks[:2]
return RetrievalResult(picked[:4], "mock")
def _retrieve_ragflow(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
client = RagflowClient()
query = " ".join(
[
slots.get("tech_category", ""),
slots.get("scenario", ""),
slots.get("key_metric", ""),
slots.get("constraints", ""),
" ".join(bundle),
"产品组合 适用场景 注意事项",
]
).strip()
dataset_ids = [item.strip() for item in settings.ragflow_dataset_ids.split(",") if item.strip()]
raw = client.retrieve(query, dataset_ids, page_size=4)
normalized = []
for index, item in enumerate(raw[:4]):
if not isinstance(item, dict):
continue
normalized.append(
{
"id": item.get("id") or f"rf-{index}",
"title": item.get("document_keyword")
or item.get("document_name")
or item.get("docnm_kwd")
or "知识片段",
"product": item.get("document_id") or "",
"content": item.get("content") or item.get("content_with_weight") or "",
"tags": [],
"score": item.get("similarity"),
}
)
warning = "" if normalized else "知识库没有召回到可用片段,方案仅供草稿,建议转人工。"
return RetrievalResult(normalized, "ragflow", warning=warning)
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
from typing import Any
from .config_loader import business_config
from .decision import evaluate_tree
from .intent import (
greeting_reply,
has_offtopic_topic,
is_domain_related,
is_greeting,
looks_like_question,
looks_off_topic,
refuse_reply,
should_write_slot,
)
from .retrieval import retrieve_for_scheme, retrieve_question
from .session_store import (
Session,
looks_like_handoff,
naive_fill_slots,
next_slot_prompt,
store,
)
def create_session() -> Session:
return store.create()
def handle_message(session_id: str, message: str) -> dict[str, Any]:
session = store.get(session_id)
if not session:
raise KeyError("session_not_found")
text = (message or "").strip()
session.messages.append({"role": "user", "content": text})
nxt = next_slot_prompt(business_config.slots, session.slots)
next_prompt = nxt["prompt"] if nxt else None
if looks_like_handoff(text, business_config.handoff_phrases):
session.phase = "handoff"
reply = "已为您标记转人工。请留下称呼与联系方式,销售同事会跟进。(原型:仅记录意图,未接 Chatwoot)"
return _finish(session, reply, need_handoff=True)
# 寒暄:回问候 + 合理追问,绝不把 hello 写进槽位
if is_greeting(text):
return _finish(session, greeting_reply(next_prompt), asking_slot=nxt["id"] if nxt else None)
# 明显跑题:拒答并拉回当前追问
if looks_off_topic(text):
return _finish(
session,
refuse_reply("off_topic", next_prompt),
asking_slot=nxt["id"] if nxt else None,
)
# 业务相关自由提问:先查知识库;没命中则拒答,不强行灌槽
# 同时夹带明显跑题话题(如「记录仪能炒股吗」)→ 直接拒答
if looks_like_question(text) and is_domain_related(text):
if has_offtopic_topic(text):
return _finish(
session,
refuse_reply("off_topic", next_prompt),
asking_slot=nxt["id"] if nxt else None,
)
qa = retrieve_question(text)
if not qa.chunks:
return _finish(
session,
refuse_reply("kb_miss", next_prompt),
asking_slot=nxt["id"] if nxt else None,
)
lines = ["根据知识库,简要说明如下:", ""]
for citation in qa.chunks[:3]:
lines.append(f"- {citation.get('title')}:{citation.get('content')}")
if next_prompt and session.phase == "collecting":
lines.append("")
lines.append(f"若要继续匹配方案:{next_prompt}")
return _finish(session, "\n".join(lines), asking_slot=nxt["id"] if nxt else None)
# 正常收集槽位
if session.phase in ("collecting", "scheme_ready") and should_write_slot(text):
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
nxt = next_slot_prompt(business_config.slots, session.slots)
if nxt and session.phase == "collecting":
reply = nxt["prompt"]
return _finish(session, reply, asking_slot=nxt["id"])
# 槽位齐了 → 出方案;知识库完全无依据时明确说明无法背书
decision = evaluate_tree(business_config.rules, session.slots)
confidence = float(decision.get("confidence") or 0)
need_handoff = bool(decision.get("handoff")) or confidence < business_config.handoff_confidence_below
bundle = list(decision.get("product_bundle") or [])
retrieval = retrieve_for_scheme(session.slots, bundle)
# 槽位内容本身不像业务需求,且检索也空 → 拒答
slot_blob = " ".join(str(v) for v in session.slots.values() if v)
if not is_domain_related(slot_blob) and not retrieval.chunks:
reply = refuse_reply("kb_miss", None)
session.phase = "collecting"
# 清空无效槽,避免一直卡在坏状态
session.slots = {}
first = next_slot_prompt(business_config.slots, session.slots)
if first:
reply = refuse_reply("off_topic", first["prompt"])
return _finish(session, reply, asking_slot=first["id"])
return _finish(session, reply)
scheme = {
"tech_line": decision.get("tech_line"),
"product_bundle": bundle,
"summary": decision.get("summary"),
"confidence": confidence,
"rule_id": decision.get("rule_id"),
"retrieval_backend": retrieval.backend,
"retrieval_warning": retrieval.warning,
"citations": [
{
"id": c.get("id"),
"title": c.get("title"),
"content": c.get("content"),
"score": c.get("score"),
}
for c in retrieval.chunks
],
"slots": dict(session.slots),
}
session.last_scheme = scheme
session.phase = "handoff" if need_handoff else "scheme_ready"
lines = [
business_config.closing_prompt,
"",
f"技术路线:{scheme['tech_line']}",
f"推荐组合:{', '.join(bundle)}",
f"说明:{scheme['summary']}",
f"置信度:{confidence:.0%}",
f"检索:{retrieval.backend}",
]
if retrieval.warning:
lines.append(f"注意:{retrieval.warning}")
if scheme["citations"]:
lines.append("")
lines.append("知识依据:")
for citation in scheme["citations"][:3]:
lines.append(f"- {citation['title']}:{citation['content']}")
else:
lines.append("")
lines.append("知识库未给出可引用片段,请勿把上述组合当成已确认报价。若问题超出资料范围,我无法保证回答准确。")
if need_handoff or retrieval.warning:
lines.append("")
lines.append("建议转人工确认后再发给客户。可在右侧留下称呼和电话。")
else:
lines.append("")
lines.append("可继续补充约束,或留下联系方式转销售确认。")
reply = "\n".join(lines)
return _finish(session, reply, scheme=scheme, need_handoff=need_handoff)
def _finish(
session: Session,
reply: str,
*,
asking_slot: str | None = None,
scheme: dict[str, Any] | None = None,
need_handoff: bool = False,
) -> dict[str, Any]:
session.messages.append({"role": "assistant", "content": reply})
store.touch(session)
return {
"session_id": session.id,
"reply": reply,
"phase": session.phase,
"slots": session.slots,
"asking_slot": asking_slot,
"scheme": scheme or session.last_scheme,
"need_handoff": need_handoff,
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class Session:
id: str
slots: dict[str, str] = field(default_factory=dict)
messages: list[dict[str, Any]] = field(default_factory=list)
phase: str = "collecting" # collecting | scheme_ready | handoff
last_scheme: dict[str, Any] | None = None
created_at: str = field(default_factory=_now)
updated_at: str = field(default_factory=_now)
class SessionStore:
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
def create(self) -> Session:
session = Session(id=str(uuid.uuid4()))
self._sessions[session.id] = session
return session
def get(self, session_id: str) -> Session | None:
return self._sessions.get(session_id)
def touch(self, session: Session) -> None:
session.updated_at = _now()
store = SessionStore()
def looks_like_handoff(text: str, phrases: list[str]) -> bool:
t = text.strip().lower()
return any(p.lower() in t for p in phrases)
def naive_fill_slots(message: str, slots_meta: list[dict[str, Any]], current: dict[str, str]) -> dict[str, str]:
"""每轮通常只推进一个槽:先做明确关键词,再把整句写入当前优先空槽。"""
from .intent import should_write_slot
updated = dict(current)
text = message.strip()
if not text or not should_write_slot(text):
return updated
touched = False
def set_slot(slot_id: str, value: str) -> None:
nonlocal touched
if not updated.get(slot_id):
updated[slot_id] = value
touched = True
if re.search(r"TagPlus|USB|PDF", text, re.I):
set_slot("tech_category", "TagPlus USB多次记录")
elif re.search(r"BlueTag|蓝牙|不开箱", text, re.I):
set_slot("tech_category", "BlueTag蓝牙手机读数")
elif re.search(r"GAEA|网关|实时", text, re.I):
set_slot("tech_category", "GAEA网关实时仓储")
elif "不确定" in text:
set_slot("tech_category", "不确定")
if "产线" in text:
set_slot("scenario", "产线")
elif "户外" in text:
set_slot("scenario", "户外监测")
elif "实验室" in text:
set_slot("scenario", "实验室小仓")
elif any(k in text for k in ("冷库", "仓储", "仓库")):
set_slot("scenario", "冷库仓储")
elif any(k in text for k in ("冷链", "药品", "疫苗", "生鲜", "冷冻", "保温箱", "运输")):
set_slot("scenario", text if len(text) <= 40 else "冷链运输")
if "Fresh Tag" in text or "fresh tag" in text.lower():
set_slot("scenario", "冷链运输")
# 本轮若已通过关键词写入至少一个槽,就不再把整句灌进其它空槽
# 但允许多个关键词槽在同一轮同时写入(如「冷库+实时」)
if touched:
return updated
pending = sorted(
[s for s in slots_meta if s.get("required") and not updated.get(s["id"])],
key=lambda s: s.get("priority", 100),
)
if pending:
updated[pending[0]["id"]] = text
return updated
optional = sorted(
[s for s in slots_meta if not s.get("required") and not updated.get(s["id"])],
key=lambda s: s.get("priority", 100),
)
if optional:
updated[optional[0]["id"]] = text
return updated
def next_slot_prompt(slots_meta: list[dict[str, Any]], filled: dict[str, str]) -> dict[str, str] | None:
pending = sorted(
[s for s in slots_meta if s.get("required") and not (filled.get(s["id"]) or "").strip()],
key=lambda s: s.get("priority", 100),
)
if not pending:
# optional slots: ask once if empty
optional = sorted(
[s for s in slots_meta if not s.get("required") and not (filled.get(s["id"]) or "").strip()],
key=lambda s: s.get("priority", 100),
)
if optional:
s = optional[0]
return {"id": s["id"], "label": s["label"], "prompt": s["prompt"]}
return None
s = pending[0]
return {"id": s["id"], "label": s["label"], "prompt": s["prompt"]}
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT = Path(__file__).resolve().parents[2]
KNOWLEDGE_DIR = ROOT / "knowledge"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=str(ROOT / ".env"), extra="ignore")
app_name: str = "鲜境智能客服"
knowledge_dir: Path = KNOWLEDGE_DIR
retrieval_backend: str = "ragflow"
ragflow_base_url: str = "http://127.0.0.1:9380"
ragflow_api_key: str = ""
ragflow_dataset_ids: str = ""
ragflow_web_url: str = "http://127.0.0.1:18080"
ragflow_email: str = "admin@xianjing.local"
ragflow_password: str = "XianjingDemo2026"
settings = Settings()
+144
View File
@@ -0,0 +1,144 @@
let sessionId = null;
const logEl = document.getElementById("log");
const slotsEl = document.getElementById("slots");
const schemeEl = document.getElementById("scheme");
const metaEl = document.getElementById("meta");
const form = document.getElementById("form");
const input = document.getElementById("input");
const statusEl = document.getElementById("status");
function addBubble(role, text) {
const div = document.createElement("div");
div.className = `bubble ${role}`;
div.textContent = text;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
async function ensureSession() {
if (sessionId) return sessionId;
const res = await fetch("/api/sessions", { method: "POST" });
const data = await res.json();
sessionId = data.session_id;
metaEl.textContent = `会话 ${sessionId.slice(0, 8)}`;
return sessionId;
}
async function sendMessage(message) {
await ensureSession();
addBubble("user", message);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, message }),
});
if (!res.ok) {
addBubble("bot", `请求失败:${res.status}`);
return;
}
const data = await res.json();
addBubble("bot", data.reply);
slotsEl.textContent = JSON.stringify(data.slots || {}, null, 2);
schemeEl.textContent = data.scheme ? JSON.stringify(data.scheme, null, 2) : "还没到出方案这一步";
metaEl.textContent = `会话 ${sessionId.slice(0, 8)} · ${data.phase}`;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
form.querySelector("button").disabled = true;
try {
await sendMessage(text);
} finally {
form.querySelector("button").disabled = false;
input.focus();
}
});
document.getElementById("reset").addEventListener("click", () => {
sessionId = null;
logEl.innerHTML = "";
slotsEl.textContent = "{}";
schemeEl.textContent = "还没到出方案这一步";
metaEl.textContent = "";
addBubble("bot", "新会话。可以直接说:「产线检测,偏向 B 类」。");
input.focus();
});
document.getElementById("handoff").addEventListener("submit", async (event) => {
event.preventDefault();
const msg = document.getElementById("handoff-msg");
if (!sessionId) {
msg.textContent = "请先开始一段咨询。";
return;
}
const body = Object.fromEntries(new FormData(event.target).entries());
const res = await fetch("/api/handoff", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, ...body }),
});
const data = await res.json();
msg.textContent = res.ok ? `已记录,编号 ${data.lead_id}` : "提交失败";
if (res.ok) loadLeads();
});
document.querySelectorAll(".tab").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((item) => item.classList.remove("active"));
document.querySelectorAll(".pane").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
document.getElementById(button.dataset.tab).classList.add("active");
if (button.dataset.tab === "knowledge") loadKnowledge();
if (button.dataset.tab === "leads") loadLeads();
});
});
async function loadHealth() {
try {
const data = await (await fetch("/api/health")).json();
const ragOk = data.ragflow && data.ragflow.ok && data.dataset_ids_configured;
statusEl.textContent = ragOk ? "Ragflow 已接入" : "Ragflow 未就绪,出方案时会标明检索失败";
statusEl.className = `status ${ragOk ? "ok" : "bad"}`;
if (data.ragflow_web) document.getElementById("ragflow-link").href = data.ragflow_web;
} catch {
statusEl.textContent = "咨询服务未连通";
statusEl.className = "status bad";
}
}
async function loadKnowledge() {
const box = document.getElementById("ragflow-status");
const files = document.getElementById("files");
const status = await (await fetch("/api/ragflow/status")).json();
box.textContent = JSON.stringify(status, null, 2);
const list = await (await fetch("/api/knowledge/files")).json();
files.innerHTML = "";
(list.files || []).forEach((file) => {
const li = document.createElement("li");
li.textContent = `${file.name} · ${file.bytes} 字节`;
files.appendChild(li);
});
}
async function loadLeads() {
const box = document.getElementById("lead-list");
const data = await (await fetch("/api/leads")).json();
if (!data.leads || !data.leads.length) {
box.textContent = "暂无";
return;
}
box.innerHTML = "";
data.leads.forEach((lead) => {
const div = document.createElement("div");
div.className = "lead";
div.textContent = `#${lead.id} ${lead.name} ${lead.phone} · ${lead.note || "无备注"} · ${lead.created_at}`;
box.appendChild(div);
});
}
addBubble("bot", "你好,我是鲜境方案客服。先问清技术路线和场景,再给出产品组合草稿,并附上知识库依据。\n试试:「户外监测,指标还不确定」。");
loadHealth();
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>鲜境智能客服</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@500;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="shell">
<header class="top">
<div>
<p class="brand">鲜境物联</p>
<h1>方案型智能客服</h1>
</div>
<div class="status" id="status">正在检查知识库…</div>
</header>
<nav class="tabs">
<button type="button" class="tab active" data-tab="consult">线上咨询</button>
<button type="button" class="tab" data-tab="knowledge">知识库</button>
<button type="button" class="tab" data-tab="leads">留资</button>
</nav>
<section id="consult" class="pane active">
<div class="layout">
<section class="panel chat">
<div id="log" class="log" aria-live="polite"></div>
<form id="form" class="composer">
<input id="input" type="text" placeholder="描述现场需求,或回答追问" autocomplete="off" required />
<button type="submit">发送</button>
</form>
<div class="toolbar">
<button type="button" id="reset" class="ghost">新会话</button>
<span id="meta" class="meta"></span>
</div>
</section>
<aside class="panel side">
<h2>已收集信息</h2>
<pre id="slots">{}</pre>
<h2>方案草稿</h2>
<pre id="scheme">还没到出方案这一步</pre>
<h2>转人工留资</h2>
<form id="handoff" class="stack">
<input name="name" placeholder="称呼" required />
<input name="phone" placeholder="电话" required />
<textarea name="note" rows="2" placeholder="补充说明,可选"></textarea>
<button type="submit">提交给销售</button>
<p id="handoff-msg" class="meta"></p>
</form>
</aside>
</div>
</section>
<section id="knowledge" class="pane">
<div class="panel pad">
<h2>Ragflow 知识库</h2>
<p class="lede">检索走自托管 Ragflow。控制台可改文档、看召回;这里只看接入状态。示例资料待甲方替换。</p>
<p><a id="ragflow-link" href="http://127.0.0.1:18080" target="_blank" rel="noreferrer">打开 Ragflow 控制台</a></p>
<pre id="ragflow-status">加载中</pre>
<h2>本地语料(灌库来源)</h2>
<ul id="files"></ul>
</div>
</section>
<section id="leads" class="pane">
<div class="panel pad">
<h2>已留资</h2>
<p class="lede">演示库存在本机 SQLite,不是正式 CRM。</p>
<div id="lead-list" class="lead-list">暂无</div>
</div>
</section>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
:root {
--bg0: #10211a;
--bg1: #1b3b2c;
--panel: #f6f8f4;
--ink: #14231c;
--muted: #5d7368;
--accent: #1d7a4d;
--line: rgba(20, 35, 28, 0.12);
--bot: #e7f3eb;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "Noto Sans SC", "DM Sans", sans-serif;
background:
radial-gradient(900px 480px at 0% 0%, #3d7a58 0%, transparent 55%),
linear-gradient(165deg, var(--bg0), var(--bg1));
}
.shell { max-width: 1120px; margin: 0 auto; padding: 24px 16px 40px; }
.top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; color: #f4faf6; }
.brand { margin: 0; font-family: "DM Sans", sans-serif; letter-spacing: 0.08em; font-size: 13px; color: #c6ef7a; }
.top h1 { margin: 4px 0 0; font-size: clamp(1.5rem, 3vw, 2rem); }
.status { font-size: 13px; padding: 8px 12px; border-radius: 999px; background: rgba(0,0,0,0.25); }
.status.ok { background: #1d7a4d; }
.status.bad { background: #8a3b2d; }
.tabs { display: flex; gap: 8px; margin: 18px 0; }
.tab {
border: 0; background: rgba(255,255,255,0.12); color: #f4faf6;
border-radius: 999px; padding: 8px 14px; font: inherit; cursor: pointer;
}
.tab.active { background: #f6f8f4; color: var(--ink); }
.pane { display: none; }
.pane.active { display: block; }
.layout { display: grid; grid-template-columns: 1.45fr 0.9fr; gap: 14px; }
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: var(--panel);
border-radius: 18px;
box-shadow: 0 16px 40px rgba(0,0,0,0.18);
}
.chat { display: flex; flex-direction: column; min-height: 560px; }
.log { flex: 1; overflow: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.bubble { max-width: 92%; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; line-height: 1.5; font-size: 14px; }
.bubble.bot { align-self: flex-start; background: var(--bot); }
.bubble.user { align-self: flex-end; background: var(--accent); color: white; }
.composer { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); }
.composer input, .stack input, .stack textarea {
width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px 12px; font: inherit;
}
.composer button, .stack button, .ghost {
border: 0; border-radius: 10px; padding: 11px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.composer button, .stack button { background: var(--accent); color: white; }
.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 0 12px 12px; }
.meta, .lede { color: var(--muted); font-size: 13px; }
.side, .pad { padding: 16px; }
.side h2, .pad h2 { margin: 14px 0 8px; font-size: 13px; letter-spacing: 0.04em; color: var(--muted); }
.side h2:first-child, .pad h2:first-child { margin-top: 0; }
pre {
margin: 0; padding: 10px; background: #eef5f0; border-radius: 10px;
font-size: 12px; overflow: auto; max-height: 180px; white-space: pre-wrap;
}
.stack { display: grid; gap: 8px; }
.lead-list { display: grid; gap: 8px; }
.lead {
padding: 10px 12px; border-radius: 10px; background: #eef5f0; font-size: 14px;
}
a { color: var(--accent); }
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.3
pydantic-settings==2.6.1
pyyaml==6.0.2
httpx==0.28.1
python-multipart==0.0.20
pycryptodome==3.21.0
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArq9XTUSeYr2+N1h3Afl/
z8Dse/2yD0ZGrKwx+EEEcdsBLca9Ynmx3nIB5obmLlSfmskLpBo0UACBmB5rEjBp
2Q2f3AG3Hjd4B+gNCG6BDaawuDlgANIhGnaTLrIqWrrcm4EMzJOnAOI1fgzJRsOO
UEfaS318Eq9OVO3apEyCCt0lOQK6PuksduOjVxtltDav+guVAA068NrPYmRNabVK
RNLJpL8w4D44sfth5RvZ3q9t+6RTArpEtc5sh5ChzvqPOzKGMXW83C95TxmXqpbK
6olN4RevSfVjEAgCydH6HN6OhtOQEcnrU97r9H0iZOWwbw3pVrZiUkuRD1R56Wzs
2wIDAQAB
-----END PUBLIC KEY-----
+17
View File
@@ -0,0 +1,17 @@
# 鲜境示例资料 · 常见询盘
## 下班后没人回怎么办
线上咨询由本系统 7×24 接待。答不上、置信度低、或客户要求人工时,留下称呼和电话,销售在工作时间跟进。
## 你们是卖设备还是卖方案
鲜境是卖方案。先判断走传感监测还是控制联动,再给产品组合,而不是一问一答式报价单。
## 能不能先给个大概价格
演示知识库没有价格。价格、折扣、交期一律转人工,不得估算。
## 资料里没有的参数
回复「资料未覆盖,需要工程师确认」,不要用常识补全型号参数。
@@ -0,0 +1,54 @@
# 鲜境物联 · 公司与首页主推产品(官网样例)
> 来源:https://www.freshliance.com/
> 公司:郑州鲜境电子有限公司(Zhengzhou Freshliance Electronics Corp., Ltd.)
> 成立:2015 年;质量管理:ISO9000 体系认证企业(官网表述)
## 公司定位
专注物联网温湿度数据记录仪,以及冷链在线环境监测与追踪方案的研发、制造与供应。重点服务生命科学、医药、果蔬、食品等冷链运输与仓储场景。
官网承诺方向:感知、监测、记录、连接(sensing / monitoring / logging),为冷链环境变化提供可追溯记录。
联系邮箱(官网):inquiry@freshliance.com
## 首页主推产品线
### 1. TagPlus-T Temperature Data Loggers
- 多次使用(multi-use)USB 温度记录仪
- 带 LCD,便于现场查看
- 参数可配置;行程结束后插电脑生成加密报告(含摘要、曲线、表格)
- 支持临时读取模式:生成临时报告后仍可继续记录
- 适合运输途中对温敏货物做温度变化记录
### 2. BlueTag T10 Bluetooth Temperature Data Logger
- 蓝牙温度记录仪,体积小、便携
- 通过手机 APP 远程配置、查看与导出,无需开箱/开柜/开集装箱
- 数据可同步云端,支持多用户同时在线查看
- APP 提供分析、筛选、曲线、报警等能力
- 适合冷链物流、疫苗运输、生鲜配送、冷藏运输、保温箱运输等
### 3. GAEA Series G1000 4G/WiFi/LAN Bluetooth Gateway
- 面向小面积仓储等场景的实时温湿度监测网关
- 网络接口可选 4G / WiFi / LAN(下单时按需求配置)
- 通过蓝牙汇聚现场记录仪数据,再上传云平台
- 可与 BlueTag 系列配合,构成实时监测系统;官网称可连接多达约 30 个传感器(G1000-S 相关页)
- 断网时终端可继续本地记录,恢复网络后补传
## 官网服务能力(SERVICE)
1. **API service**
简单易用;可与 IoT 平台集成;可把实时与历史传感数据拉到自有网站或看板做分析与集中管理。
2. **Tailored Solutions(定制方案)**
IoT 平台集成、远程实时洞察与告警、自动数据处理与报告、多地点设备集中管理、基于角色的多级权限;官网提及符合 FDA 21 CFR Part 11。
3. **Configuration Download**
提供记录仪对应的软件、手机 APP、云平台;用于配置与激活、数据可视化管理、PDF/CSV 报告生成与下载。
## 对外边界
本样例仅整理官网首页及首页关联产品公开信息。价格、交期、折扣、具体认证报告编号不在此承诺,客户追问转销售(inquiry@freshliance.com)。
@@ -0,0 +1,61 @@
# 鲜境物联 · 首页产品选型说明(样例)
> 依据 https://www.freshliance.com/ 首页主推的 TagPlus-T、BlueTag T10、GAEA G1000,整理咨询时如何判定路线。
> 目标:先判断「运输记录 / 蓝牙查数 / 仓储实时」,再推荐对应产品组合。
## 三条主路线
| 客户需求关键词 | 推荐方向 | 首页对应产品 |
|----------------|----------|--------------|
| USB 插电脑出报告、可重复用、运输途中看数/到货出 PDF | 多次使用 USB 记录 | **TagPlus-T** |
| 不开箱用手机看温度、蓝牙配置、多用户同步 | 蓝牙记录 + APP | **BlueTag T10** |
| 冷库/小仓实时看数、云端告警、多探头汇聚 | 网关 + 蓝牙终端 | **GAEA G1000 + BlueTag 系列** |
## 追问槽位(客服必问)
1. 场景:运输途中,还是库房/冷柜长期监测?
2. 取数方式:到货插电脑出报告,还是手机/云端随时看?
3. 是否需要实时告警(短信/邮件/APP)?
4. 是否多次重复使用,还是单次行程一次性?
5. 网络条件:现场有 WiFi/LAN,还是需要 4G?
6. 是否要对接自有系统(API / 私有云 / ERP/WMS)?
## 判定建议
### A. 推荐 TagPlus-T
- 客户说:冷链运输、到货要 PDF/CSV、希望可配置、可多次用、有屏幕看当前温度
- 强调:加密报告、临时读取后可继续记、适合温敏货在途记录
### B. 推荐 BlueTag T10
- 客户说:不想开箱读数、手机 APP 配置、疫苗/生鲜/保温箱运输、多人同时看数据
- 强调:蓝牙无线读数、云端同步、分析与报警
### C. 推荐 GAEA G1000(常与 BlueTag 组合)
- 客户说:冷库、实验室小仓、小面积仓储要实时温湿度、要远程告警、要集中管理多点
- 强调:4G/WiFi/LAN 可选、蓝牙汇聚多终端、断点续传、APP FreshLink / 云平台
### D. 服务加项
- 要对接自有看板/系统 → 介绍 **API service**
- 要多仓权限、自动报告、Part 11 合规诉求 → 介绍 **Tailored Solutions**
- 只要软件/APP/配置工具 → 介绍 **Configuration Download**
## 不要硬推的情况
- 只要一次性极低成本 USB、不关心重复使用 → 不要只推 TagPlus-T,转人工核对是否有一次性系列
- 大面积厂区复杂组网、无官网明确型号覆盖 → 转人工做定制评估
- 价格、交期、合同条款 → 一律转销售,邮箱 inquiry@freshliance.com
## 咨询话术示例
客户:「我们药品冷链运输,到货要出报告。」
→ 先确认是否可重复使用、要不要屏幕;倾向 **TagPlus-T**。
客户:「保温箱运输,不能开箱,司机用手机看温度。」
→ 倾向 **BlueTag T10**。
客户:「小冷库要 7×24 实时看,断网还能补数据。」
→ 倾向 **GAEA G1000 + BlueTag**,并确认 WiFi/4G/LAN。
+13
View File
@@ -0,0 +1,13 @@
# 鲜境示例资料 · 对外边界
## 敏感与承诺
- 不报未入库的精度、认证、交期、折扣、质保年限。
- 客户留下的电话只用于本次跟进,回复中不得复述其他客户信息。
- 价格一律转人工。
## 转人工触发
- 客户明确说转人工、找销售、要真人。
- 判定置信度低于 0.55。
- 知识库没有命中依据却被追问具体参数。
+28
View File
@@ -0,0 +1,28 @@
# 鲜境示例资料 · A 类传感监测
> 本文件是演示知识,型号与参数为占位。正式上线前替换为甲方产品手册。未写入本库的参数,客服不得对外承诺。
## 技术路线
A 类面向监测与数据采集:现场传感器采集、网关汇聚、远程查看。典型客户说的是「要看数据、要报警、要远程」,而不是「要联动产线设备」。
## 推荐组合
- 主机:XJ-A100
- 网关:XJ-A-GW
XJ-A100 适用于户外与环境监测,宽温工作,需配合 XJ-A-GW 做多节点汇聚与协议转换。不要单独报主机而不报网关。
## 适用场景
- 户外监测
- 环境采集
- 实验室标定(轻量)
## 必问再确认
防护等级、供电方式、是否已有采集平台。这三项资料未齐时,只能给草稿,建议转销售确认。
## 不适合
产线设备联动、大量 IO 点位、本地逻辑控制。这些走 B 类控制路线。
+28
View File
@@ -0,0 +1,28 @@
# 鲜境示例资料 · B 类控制联动
> 本文件是演示知识,型号与参数为占位。正式上线前替换为甲方产品手册。未写入本库的参数,客服不得对外承诺。
## 技术路线
B 类面向产线与设备控制:本地逻辑、IO 扩展、与已有设备联动。客户常说「要控设备、要对接点位、要跟产线走」。
## 推荐组合
- 控制主机:XJ-B200
- IO 扩展:XJ-B-IO
XJ-B200 支持本地逻辑与扩展 IO。XJ-B-IO 用于增加数字量或模拟量通道,应与 XJ-B200 配套报价,不要只报扩展模块。
## 适用场景
- 产线检测
- 设备联动
- 现场控制柜改造
## 必问再确认
点位数量、已有 PLC 或上位机、安装空间、是否需要改造停电窗口。
## 不适合
纯远程环境监测、只要看数不要控制。这些走 A 类传感路线。
+22
View File
@@ -0,0 +1,22 @@
# 鲜境示例资料 · 方案判定说明
> 演示用判定说明,与 knowledge/decision_tree.yaml 一致。业务专家确认后以 YAML 为准。
## 先问再出方案
1. 技术大类倾向:A 类传感 / B 类控制 / 不确定
2. 应用场景:产线、户外、实验室等
3. 关键指标:量程、精度、点位或响应时间;没有就记「暂无」
4. 现场约束:空间、已有设备、预算、交付区域;没有就记「暂无」
## 判定顺序
1. 客户明确要 A 类或传感:推荐 XJ-A100 + XJ-A-GW
2. 客户明确要 B 类或控制:推荐 XJ-B200 + XJ-B-IO
3. 未指定大类但场景是产线:先按 B 类给草稿,并写明需人工确认
4. 未指定大类但场景是户外监测:先按 A 类给草稿,并写明需确认防护与供电
5. 以上都不像:不要硬推型号,转人工
## 对外话术边界
方案正文只能复述知识库里出现过的事实。客户追问具体认证、交期、折扣时,一律转销售,不编造。
+147
View File
@@ -0,0 +1,147 @@
# 判定树:对齐 freshliance.com 首页主推产品
version: 1
rules:
- id: tagplus_explicit
order: 5
description: 明确要 TagPlus / USB 多次记录 / 到货 PDF
when_all:
- type: slot_contains
slot: tech_category
value: TagPlus
then:
product_bundle: ["TagPlus-T"]
tech_line: 多次使用USB温度记录
summary: 按官网首页主推匹配 TagPlus-T:可配置、LCD、USB 加密 PDF/CSV,适合运输途中温敏货记录。
confidence: 0.88
- id: usb_pdf_transport
order: 8
description: USB/PDF/到货报告 + 运输
when_all:
- type: slot_contains
slot: key_metric
value: PDF
then:
product_bundle: ["TagPlus-T"]
tech_line: 多次使用USB温度记录
summary: 需要到货 PDF/报告时,优先推荐 TagPlus-T(首页主推 USB 温度记录仪)。
confidence: 0.82
- id: bluetag_explicit
order: 10
description: 明确要蓝牙 / BlueTag / 不开箱
when_all:
- type: slot_contains
slot: tech_category
value: BlueTag
then:
product_bundle: ["BlueTag T10"]
tech_line: 蓝牙温度记录
summary: 按官网首页主推匹配 BlueTag T10:蓝牙+APP 配置与读数,适合不开箱查看。
confidence: 0.88
- id: no_open_box
order: 12
description: 不开箱 / 手机读数
when_all:
- type: slot_contains
slot: key_metric
value: 不开箱
then:
product_bundle: ["BlueTag T10"]
tech_line: 蓝牙温度记录
summary: 需要不开箱读数时推荐 BlueTag T10;若还要仓储实时汇聚,可再评估加 GAEA 网关。
confidence: 0.84
- id: gaea_explicit
order: 14
description: 明确要网关 / GAEA / 实时仓储
when_all:
- type: slot_contains
slot: tech_category
value: GAEA
then:
product_bundle: ["GAEA G1000", "BlueTag 系列"]
tech_line: 网关实时监测
summary: 按官网首页主推匹配 GAEA G1000:4G/WiFi/LAN 网关汇聚蓝牙终端,适合小面积仓储实时监测。
confidence: 0.86
- id: realtime_storage
order: 16
description: 冷库/仓储实时告警
when_all:
- type: slot_contains
slot: scenario
value: 冷库
then:
product_bundle: ["GAEA G1000", "BlueTag 系列"]
tech_line: 网关实时监测
summary: 冷库/仓储实时监测优先 GAEA G1000 + BlueTag;需确认现场网络(4G/WiFi/LAN)。
confidence: 0.83
- id: warehouse
order: 17
description: 仓储场景
when_all:
- type: slot_contains
slot: scenario
value: 仓储
then:
product_bundle: ["GAEA G1000", "BlueTag 系列"]
tech_line: 网关实时监测
summary: 仓储环境监测按首页方案推荐 GAEA 网关组合;点数与组网方式需人工确认。
confidence: 0.8
- id: vaccine_bluetag
order: 20
description: 疫苗/药品运输偏蓝牙查数
when_all:
- type: slot_contains
slot: scenario
value: 疫苗
then:
product_bundle: ["BlueTag T10"]
tech_line: 蓝牙温度记录
summary: 疫苗运输场景官网示例适配 BlueTag T10;若只要到货 USB 报告也可评估 TagPlus-T,请再确认取数方式。
confidence: 0.8
- id: cold_chain_transport
order: 25
description: 冷链运输默认 USB 多次记录
when_all:
- type: slot_contains
slot: scenario
value: 冷链
then:
product_bundle: ["TagPlus-T"]
tech_line: 多次使用USB温度记录
summary: 冷链运输通用场景先推荐首页主推 TagPlus-T;若强调手机不开箱读数则改 BlueTag T10。
confidence: 0.76
- id: fresh_food
order: 26
description: 生鲜配送
when_all:
- type: slot_contains
slot: scenario
value: 生鲜
then:
product_bundle: ["BlueTag T10"]
tech_line: 蓝牙温度记录
summary: 生鲜配送官网示例适配 BlueTag T10;需要到货加密 PDF 时可改推 TagPlus-T。
confidence: 0.78
- id: fallback_uncertain
order: 1000
description: 兜底
when_all:
- type: always
then:
product_bundle: ["待专家确认"]
tech_line: 待判定
summary: 信息不足,建议补充「运输还是仓储、USB报告还是手机/云端实时」,或转人工。联系 inquiry@freshliance.com。
confidence: 0.4
handoff: true
handoff_confidence_below: 0.55
+31
View File
@@ -0,0 +1,31 @@
# Mock 检索语料:无 Ragflow 时用于演示「带引用的方案」
chunks:
- id: c1
title: XJ-A100 产品要点
product: XJ-A100
content: XJ-A100 适用于户外与环境监测类场景,支持宽温工作,需配合 XJ-A-GW 网关做数据汇聚。
tags: [A, 户外, 传感]
- id: c2
title: XJ-A-GW 网关
product: XJ-A-GW
content: XJ-A-GW 提供多节点接入与协议转换,常与 XJ-A100 组成监测方案。
tags: [A, 网关]
- id: c3
title: XJ-B200 控制主机
product: XJ-B200
content: XJ-B200 面向产线联动与设备控制,支持本地逻辑与扩展 IO。
tags: [B, 产线, 控制]
- id: c4
title: XJ-B-IO 扩展
product: XJ-B-IO
content: XJ-B-IO 用于扩展数字量/模拟量通道,与 XJ-B200 配套。
tags: [B, IO]
- id: c5
title: 方案注意事项
product: GENERAL
content: 正式报价前需确认现场供电、安装空间与是否已有同类设备,避免重复建设。
tags: [通用, 约束]
+36
View File
@@ -0,0 +1,36 @@
# 追问槽位:需求未定稿时可随时增删字段;引擎按 priority 顺序问未填项
version: 1
intent: solution_consult
slots:
- id: tech_category
label: 方案路线倾向
prompt: 更倾向哪一类?(TagPlus USB多次记录 / BlueTag蓝牙手机读数 / GAEA网关实时仓储 / 不确定,希望推荐)
required: true
priority: 10
extract_hint: USB、蓝牙、网关实时、不确定
- id: scenario
label: 应用场景
prompt: 主要用在什么场景?(冷链运输、药品/疫苗、生鲜配送、冷库仓储、实验室小仓、保温箱等)
required: true
priority: 20
- id: key_metric
label: 关键要求
prompt: 有没有必须满足的要求?(实时告警、到货PDF报告、不开箱读数、行程天数、温区等;没有可说「暂无」)
required: true
priority: 30
- id: constraints
label: 现场约束
prompt: 现场有哪些约束?(网络:WiFi/4G/LAN、是否对接自有系统API、预算区间等;没有可说「暂无」)
required: false
priority: 40
closing_prompt: 信息已较完整,正在按鲜境官网产品线匹配方案…
handoff_phrases:
- 转人工
- 人工客服
- 找销售
- 真人
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""注册 Ragflow 账号、建库、灌入 knowledge/corpus,并写回仓库 .env。"""
from __future__ import annotations
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from engine.app.ragflow_client import RagflowClient, RagflowError # noqa: E402
from engine.app.settings import settings # noqa: E402
CORPUS = {
"products": ["products-a.md", "products-b.md"],
"solutions": ["solutions.md"],
"faqs": ["faq.md", "policies.md"],
}
DESCRIPTIONS = {
"products": "鲜境示例产品资料:A 类传感、B 类控制",
"solutions": "鲜境示例方案判定说明",
"faqs": "鲜境示例询盘与对外边界",
}
def wait_healthy(client: RagflowClient, timeout: int = 180) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
health = client.health()
if health.get("ok"):
print("ragflow health ok", health)
return
print("waiting ragflow", health)
time.sleep(5)
raise SystemExit("Ragflow 未在时限内就绪")
def auth_or_register(client: RagflowClient) -> str:
try:
return client.register(settings.ragflow_email, settings.ragflow_password, "鲜境管理员")
except RagflowError as exc:
text = str(exc)
if "already" in text.lower() or "已经" in text or "registered" in text.lower():
return client.login(settings.ragflow_email, settings.ragflow_password)
raise
def upsert_datasets(client: RagflowClient) -> dict[str, str]:
existing = {item.get("name"): item for item in client.list_datasets()}
ids: dict[str, str] = {}
for name, description in DESCRIPTIONS.items():
found = existing.get(name)
if found:
ids[name] = found["id"]
print("reuse dataset", name, found["id"])
continue
created = client.create_dataset(name, description)
dataset_id = created.get("id")
if not dataset_id:
raise SystemExit(f"创建数据集失败: {created}")
ids[name] = dataset_id
print("created dataset", name, dataset_id)
embed = {
"embedding_model": "BAAI/bge-small-zh-v1.5@Builtin",
"parser_config": {
"graphrag": {"use_graphrag": False},
"raptor": {"use_raptor": False},
},
}
for dataset_id in ids.values():
client.update_dataset(dataset_id, embed)
return ids
def ingest(client: RagflowClient, dataset_ids: dict[str, str]) -> None:
corpus_dir = ROOT / "knowledge" / "corpus"
for name, files in CORPUS.items():
dataset_id = dataset_ids[name]
docs = client.list_documents(dataset_id)
present = {item.get("name") or item.get("location") for item in docs}
new_ids = []
for filename in files:
if filename in present:
print("skip existing", filename)
continue
uploaded = client.upload_document(dataset_id, corpus_dir / filename)
for item in uploaded:
doc_id = item.get("id")
if doc_id:
new_ids.append(doc_id)
print("uploaded", filename, new_ids[-1] if new_ids else uploaded)
if new_ids:
client.parse_documents(dataset_id, new_ids)
print("parse queued", name, new_ids)
def wait_chunks(client: RagflowClient, dataset_ids: dict[str, str], timeout: int = 300) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
pending = 0
for dataset_id in dataset_ids.values():
for doc in client.list_documents(dataset_id):
run = str(doc.get("run") or "")
chunks = int(doc.get("chunk_count") or 0)
if chunks > 0 or run.upper() in {"DONE", "3", "SUCCESS"}:
continue
if run.upper() in {"FAIL", "4", "FAILED"}:
raise SystemExit(f"解析失败 {doc.get('name')}: {(doc.get('progress_msg') or '')[-300:]}")
pending += 1
print("parsing", doc.get("name"), "run=", run, "chunks=", chunks)
if pending == 0:
print("all documents parsed")
return
time.sleep(6)
print("warn: parse still running, continue anyway")
def write_env(dataset_ids: dict[str, str], api_key: str) -> None:
env_path = ROOT / ".env"
lines = [
"RETRIEVAL_BACKEND=ragflow",
"RAGFLOW_BASE_URL=http://127.0.0.1:9380",
f"RAGFLOW_API_KEY={api_key}",
"RAGFLOW_DATASET_IDS=" + ",".join(dataset_ids[name] for name in ("products", "solutions", "faqs")),
"RAGFLOW_WEB_URL=http://127.0.0.1:18080",
f"RAGFLOW_EMAIL={settings.ragflow_email}",
f"RAGFLOW_PASSWORD={settings.ragflow_password}",
"",
]
env_path.write_text("\n".join(lines), encoding="utf-8")
print("wrote", env_path)
def main() -> None:
client = RagflowClient(api_key="")
wait_healthy(client)
session_auth = auth_or_register(client)
token = client.ensure_api_token(session_auth)
client.api_key = token
dataset_ids = upsert_datasets(client)
ingest(client, dataset_ids)
wait_chunks(client, dataset_ids)
write_env(dataset_ids, token)
print("bootstrap done")
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Docker 在部分环境里只写了 nft 规则,内核仍走 iptables-legacy 且 FORWARD 为 DROP,
# 容器之间会连不上 MySQL。本脚本给 Ragflow 网桥补上转发放行。
set -euo pipefail
bridge=$(ip -br link | awk '/^br-/ && $2 ~ /UP/ {print $1; exit}')
if [[ -z "${bridge}" ]]; then
echo "no up bridge" >&2
exit 1
fi
sudo iptables-legacy -C FORWARD -i "$bridge" -j ACCEPT 2>/dev/null || sudo iptables-legacy -I FORWARD 1 -i "$bridge" -j ACCEPT
sudo iptables-legacy -C FORWARD -o "$bridge" -j ACCEPT 2>/dev/null || sudo iptables-legacy -I FORWARD 1 -o "$bridge" -j ACCEPT
echo "forward allowed on ${bridge}"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.