feat: 可配置方案引擎通用原型(Mock 检索)
需求未定稿时可先跑通追问/判定/出方案;槽位与判定树外置 YAML,后续可接 Ragflow。 Co-authored-by: haijd <haijd@163.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
@@ -1,37 +1,51 @@
|
|||||||
# 鲜境物联 · AI 智能客服|技术路线建议
|
# 鲜境方案引擎 · 通用原型(需求未定稿版)
|
||||||
|
|
||||||
综合建议书、验收标准、**委托开发合同**与**附件二进度表**后的实施路线结论。
|
业务规则还在变时:**先搭可配置原型,再按真实需求改 YAML,而不是先写死后台。**
|
||||||
|
|
||||||
## 结论
|
## 策略
|
||||||
|
|
||||||
**推荐:开源自托管二开 + 核心业务定制。不建议纯自研,不建议纯公有云 SaaS。**
|
1. 先跑通:多轮追问(槽位)→ 判定树出组合 → 检索补依据 → 出方案草稿
|
||||||
|
2. 槽位 / 判定树 / 示例知识 **全部外置配置**,需求变了主要改配置
|
||||||
|
3. 检索默认 **Mock**(不依赖本机 Ragflow);有 Ragflow 后改环境变量即可接真库
|
||||||
|
4. 不做大而全后台;控制台能聊天验证闭环即可
|
||||||
|
|
||||||
| 路线 | 结论 |
|
## 本仓库原型包含
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 纯自研 | 不推荐首期(撞上 **7 周 / ¥5 万** 硬顶) |
|
| `engine/` | FastAPI 方案引擎 + 简易聊天页 |
|
||||||
| 纯 SaaS(扣子 / 阿里云客服等) | 不推荐(数据自主可控冲突) |
|
| `knowledge/` | `slots.yaml` / `decision_tree.yaml` / 示例检索片段 |
|
||||||
| **Dify 自托管二开 + 方案判定定制** | **推荐** |
|
| `docs/Ragflow实施方案.md` | 完整架构说明 |
|
||||||
|
|
||||||
**二开首选:Dify**;检索吃力时加 **Ragflow**;人工席位 **Chatwoot**(或企微/飞书)。
|
## 快速启动
|
||||||
|
|
||||||
合同已锁:实施 **¥50,000**、订阅 **¥30,000/年**、周期 **7 周**、线上场景 **只做一个高频渠道**。详见完整建议书。
|
```bash
|
||||||
|
cd /workspace
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r engine/requirements.txt
|
||||||
|
uvicorn engine.app.main:app --host 0.0.0.0 --port 3847
|
||||||
|
```
|
||||||
|
|
||||||
## 文档
|
打开:[方案引擎原型](http://127.0.0.1:3847/)
|
||||||
|
|
||||||
| 文件 | 说明 |
|
健康检查:`GET /api/health`
|
||||||
|------|------|
|
重载业务配置:`POST /api/admin/reload-config`(改完 YAML 不用重启进程时用)
|
||||||
| [docs/技术方案建议书.md](./docs/技术方案建议书.md) | 自研/二开决策、Dify vs Ragflow、许可 |
|
|
||||||
| [docs/Ragflow实施方案.md](./docs/Ragflow实施方案.md) | **选定 Ragflow 后怎么搭**(架构、知识库、方案引擎、分期) |
|
|
||||||
| [uploads/](./uploads/) | 原始合同、进度表、建议书、验收标准 |
|
|
||||||
|
|
||||||
## 若已定 Ragflow
|
## 接真 Ragflow(稍后)
|
||||||
|
|
||||||
按 [Ragflow实施方案](./docs/Ragflow实施方案.md):自托管 Ragflow 做检索 → 自研方案引擎做追问/判定/转人工 → 首期只接一个渠道。
|
```bash
|
||||||
|
export RETRIEVAL_BACKEND=ragflow
|
||||||
|
export RAGFLOW_BASE_URL=http://127.0.0.1:9380
|
||||||
|
export RAGFLOW_API_KEY=...
|
||||||
|
export RAGFLOW_DATASET_IDS=...
|
||||||
|
```
|
||||||
|
|
||||||
## 建议立刻确认
|
未配置时保持 `RETRIEVAL_BACKEND=mock`。
|
||||||
|
|
||||||
1. 首期唯一渠道:网站还是 Facebook?
|
## 需求清晰后怎么补
|
||||||
2. 方案判定树的业务拍板人
|
|
||||||
3. 模型:智谱 API 是否可上生产 / 是否必须私有化
|
1. 改 `knowledge/slots.yaml`(追问项)
|
||||||
4. 场景二(录音辅助)是否与场景一同期验收
|
2. 改 `knowledge/decision_tree.yaml`(推荐规则)
|
||||||
5. 部署:甲方机房还是乙方 VPC
|
3. 灌真实资料进 Ragflow,切 `ragflow` 后端
|
||||||
|
4. 再加渠道 Webhook / 转人工 / Eval,而不是推倒重来
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 需求未定稿时:先搭通用原型的步骤
|
||||||
|
|
||||||
|
## 要不要先搭原型?
|
||||||
|
|
||||||
|
**要。** 做成「配置驱动的通用骨架」,不要等需求 100% 精确再动手。
|
||||||
|
|
||||||
|
- 先验证:追问 → 判定 → 检索引用 → 出方案草稿
|
||||||
|
- 业务变了:主要改 `knowledge/*.yaml`
|
||||||
|
- 后台/渠道:闭环跑通后再补
|
||||||
|
|
||||||
|
## 具体步骤
|
||||||
|
|
||||||
|
1. **拉起本原型**(不必先装 Ragflow)
|
||||||
|
2. **和业务改槽位/判定树 YAML**(边聊边改,`POST /api/admin/reload-config`)
|
||||||
|
3. **灌真实小样本到 Ragflow**,切 `RETRIEVAL_BACKEND=ragflow`
|
||||||
|
4. **再补**转人工、单渠道、Eval、薄后台
|
||||||
|
|
||||||
|
不要颠倒:先做大后台或双渠道。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 .scheme_engine import create_session, handle_message
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name, version="0.1.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
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> FileResponse:
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"app": settings.app_name,
|
||||||
|
"retrieval_backend": settings.retrieval_backend,
|
||||||
|
"slots": len(business_config.slots),
|
||||||
|
"rules": len(business_config.rules),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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.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
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config_loader import business_config
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalResult:
|
||||||
|
def __init__(self, chunks: list[dict[str, Any]]):
|
||||||
|
self.chunks = chunks
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_for_scheme(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||||
|
backend = (settings.retrieval_backend or "mock").lower()
|
||||||
|
if backend == "ragflow":
|
||||||
|
return _retrieve_ragflow(slots, bundle)
|
||||||
|
return _retrieve_mock(slots, bundle)
|
||||||
|
|
||||||
|
|
||||||
|
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 c in chunks:
|
||||||
|
if c.get("product") in bundle_set:
|
||||||
|
picked.append(c)
|
||||||
|
# tag overlap with slot values
|
||||||
|
blob = " ".join(slots.values())
|
||||||
|
for c in chunks:
|
||||||
|
if c in picked:
|
||||||
|
continue
|
||||||
|
tags = c.get("tags") or []
|
||||||
|
if any(str(t) in blob for t in tags):
|
||||||
|
picked.append(c)
|
||||||
|
if len(picked) >= 4:
|
||||||
|
break
|
||||||
|
if not picked:
|
||||||
|
picked = chunks[:2]
|
||||||
|
return RetrievalResult(picked[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieve_ragflow(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||||
|
"""Minimal Ragflow retrieval hook. Falls back to mock if not configured/unavailable."""
|
||||||
|
if not settings.ragflow_api_key:
|
||||||
|
return _retrieve_mock(slots, bundle)
|
||||||
|
|
||||||
|
query = " ".join(
|
||||||
|
[
|
||||||
|
slots.get("tech_category", ""),
|
||||||
|
slots.get("scenario", ""),
|
||||||
|
slots.get("key_metric", ""),
|
||||||
|
" ".join(bundle),
|
||||||
|
]
|
||||||
|
).strip() or "产品方案"
|
||||||
|
dataset_ids = [x for x in settings.ragflow_dataset_ids.split(",") if x.strip()]
|
||||||
|
url = settings.ragflow_base_url.rstrip("/") + "/api/v1/retrieval"
|
||||||
|
headers = {"Authorization": f"Bearer {settings.ragflow_api_key}"}
|
||||||
|
payload: dict[str, Any] = {"question": query, "top_k": 4}
|
||||||
|
if dataset_ids:
|
||||||
|
payload["dataset_ids"] = dataset_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
resp = client.post(url, headers=headers, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return _retrieve_mock(slots, bundle)
|
||||||
|
|
||||||
|
# Ragflow response shapes vary by version; normalize best-effort
|
||||||
|
records = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
records = data.get("data") or data.get("chunks") or data.get("records") or []
|
||||||
|
if isinstance(records, dict):
|
||||||
|
records = records.get("chunks") or records.get("records") or []
|
||||||
|
normalized = []
|
||||||
|
for i, r in enumerate(records[:4]):
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"id": r.get("id") or f"rf-{i}",
|
||||||
|
"title": r.get("document_keyword") or r.get("title") or "Ragflow片段",
|
||||||
|
"product": r.get("document_id") or "",
|
||||||
|
"content": r.get("content") or r.get("text") or "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not normalized:
|
||||||
|
return _retrieve_mock(slots, bundle)
|
||||||
|
return RetrievalResult(normalized)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config_loader import business_config
|
||||||
|
from .decision import evaluate_tree
|
||||||
|
from .retrieval import retrieve_for_scheme
|
||||||
|
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})
|
||||||
|
|
||||||
|
if looks_like_handoff(text, business_config.handoff_phrases):
|
||||||
|
session.phase = "handoff"
|
||||||
|
reply = "已为您标记转人工。请留下称呼与联系方式,销售同事会跟进。(原型:仅记录意图,未接 Chatwoot)"
|
||||||
|
return _finish(session, reply, need_handoff=True)
|
||||||
|
|
||||||
|
# fill slots while collecting
|
||||||
|
if session.phase in ("collecting", "scheme_ready"):
|
||||||
|
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":
|
||||||
|
# first turn with empty slots: greet + first question
|
||||||
|
if len(session.messages) == 1 and not any(session.slots.values()):
|
||||||
|
# put first user utterance into first slot if they already answered something
|
||||||
|
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
||||||
|
nxt = next_slot_prompt(business_config.slots, session.slots)
|
||||||
|
if nxt:
|
||||||
|
reply = nxt["prompt"]
|
||||||
|
return _finish(session, reply, asking_slot=nxt["id"])
|
||||||
|
|
||||||
|
# all required (+ optional asked or filled) → scheme
|
||||||
|
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)
|
||||||
|
|
||||||
|
scheme = {
|
||||||
|
"tech_line": decision.get("tech_line"),
|
||||||
|
"product_bundle": bundle,
|
||||||
|
"summary": decision.get("summary"),
|
||||||
|
"confidence": confidence,
|
||||||
|
"rule_id": decision.get("rule_id"),
|
||||||
|
"citations": [
|
||||||
|
{"id": c.get("id"), "title": c.get("title"), "content": c.get("content")}
|
||||||
|
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%}",
|
||||||
|
]
|
||||||
|
if scheme["citations"]:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("知识依据(原型检索):")
|
||||||
|
for c in scheme["citations"][:3]:
|
||||||
|
lines.append(f"- {c['title']}:{c['content']}")
|
||||||
|
if need_handoff:
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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]:
|
||||||
|
"""每轮通常只推进一个槽:先做明确关键词,再把整句写入当前优先空槽。"""
|
||||||
|
updated = dict(current)
|
||||||
|
text = message.strip()
|
||||||
|
if not 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"A类|传感", text, re.I):
|
||||||
|
set_slot("tech_category", "A类传感")
|
||||||
|
elif re.search(r"B类|控制", text, re.I):
|
||||||
|
set_slot("tech_category", "B类控制")
|
||||||
|
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", "实验室")
|
||||||
|
|
||||||
|
# 本轮若已通过关键词写入至少一个槽,就不再把整句灌进其它空槽
|
||||||
|
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"]}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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=".env", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "鲜境方案引擎原型"
|
||||||
|
knowledge_dir: Path = KNOWLEDGE_DIR
|
||||||
|
retrieval_backend: str = "mock" # mock | ragflow
|
||||||
|
ragflow_base_url: str = "http://127.0.0.1:9380"
|
||||||
|
ragflow_api_key: str = ""
|
||||||
|
ragflow_dataset_ids: str = ""
|
||||||
|
llm_mode: str = "template" # template(无 Key 可跑)| openai_compatible(预留)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 resetBtn = document.getElementById("reset");
|
||||||
|
|
||||||
|
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 = `session: ${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("sys", `错误:${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 = `session: ${sessionId.slice(0, 8)}… · phase: ${data.phase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn.addEventListener("click", () => {
|
||||||
|
sessionId = null;
|
||||||
|
logEl.innerHTML = "";
|
||||||
|
slotsEl.textContent = "{}";
|
||||||
|
schemeEl.textContent = "尚未生成";
|
||||||
|
metaEl.textContent = "";
|
||||||
|
addBubble("bot", "新会话已开始。可以直接说场景,例如:「产线检测,偏向 B 类」。");
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
addBubble("bot", "你好,我是鲜境方案引擎原型。先收集几个关键信息,再给出产品组合草稿。\n试试:「我们在户外做监测,指标还不确定」。");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!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@400;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<p class="brand">鲜境物联 · 方案引擎原型</p>
|
||||||
|
<h1>先跑通「追问 → 判定 → 出方案」</h1>
|
||||||
|
<p class="sub">槽位与判定树在 <code>knowledge/*.yaml</code>,需求变了改配置即可。当前检索为 Mock,可后再接 Ragflow。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="chat panel">
|
||||||
|
<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="side panel">
|
||||||
|
<h2>当前槽位</h2>
|
||||||
|
<pre id="slots">{}</pre>
|
||||||
|
<h2>最近方案</h2>
|
||||||
|
<pre id="scheme">尚未生成</pre>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
:root {
|
||||||
|
--bg0: #0f1c17;
|
||||||
|
--bg1: #163528;
|
||||||
|
--panel: rgba(248, 250, 246, 0.94);
|
||||||
|
--ink: #14231c;
|
||||||
|
--muted: #5c7268;
|
||||||
|
--accent: #1f7a4c;
|
||||||
|
--accent-2: #c4f06a;
|
||||||
|
--line: rgba(20, 35, 28, 0.12);
|
||||||
|
--bot: #e7f2ea;
|
||||||
|
--user: #1f7a4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Noto Sans SC", "DM Sans", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, #2f6b4f 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, #8fd36a 0%, transparent 45%),
|
||||||
|
linear-gradient(160deg, var(--bg0), var(--bg1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page { max-width: 1100px; margin: 0 auto; padding: 32px 20px 48px; }
|
||||||
|
.hero { color: #f4faf6; margin-bottom: 22px; }
|
||||||
|
.brand {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: "DM Sans", sans-serif;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.2rem);
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sub { margin: 0; max-width: 52rem; color: rgba(244, 250, 246, 0.82); }
|
||||||
|
.sub code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 0.9fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
box-shadow: 0 18px 50px rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat { display: flex; flex-direction: column; min-height: 560px; overflow: hidden; }
|
||||||
|
.log {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
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(--user); color: #fff; }
|
||||||
|
.bubble.sys { align-self: center; background: transparent; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.composer input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.composer button, .ghost {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
.meta { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.side { padding: 16px 18px 20px; }
|
||||||
|
.side h2 {
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.side h2:first-child { margin-top: 0; }
|
||||||
|
.side pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: #eef5f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 判定树:规则按 order 依次匹配,命中第一条即输出推荐
|
||||||
|
# 条件支持:slot_equals / slot_contains / always
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- id: prefer_a_line
|
||||||
|
order: 10
|
||||||
|
description: 明确倾向 A 类或传感场景
|
||||||
|
when_all:
|
||||||
|
- type: slot_contains
|
||||||
|
slot: tech_category
|
||||||
|
value: A
|
||||||
|
then:
|
||||||
|
product_bundle: ["XJ-A100", "XJ-A-GW"]
|
||||||
|
tech_line: A类传感
|
||||||
|
summary: 按 A 类传感路线匹配主机 + 网关组合,适合监测类诉求。
|
||||||
|
confidence: 0.82
|
||||||
|
|
||||||
|
- id: prefer_b_line
|
||||||
|
order: 20
|
||||||
|
description: 明确倾向 B 类或控制/产线
|
||||||
|
when_all:
|
||||||
|
- type: slot_contains
|
||||||
|
slot: tech_category
|
||||||
|
value: B
|
||||||
|
then:
|
||||||
|
product_bundle: ["XJ-B200", "XJ-B-IO"]
|
||||||
|
tech_line: B类控制
|
||||||
|
summary: 按 B 类控制路线匹配控制器 + IO 扩展,适合产线联动。
|
||||||
|
confidence: 0.8
|
||||||
|
|
||||||
|
- id: scenario_production
|
||||||
|
order: 30
|
||||||
|
description: 未指定大类但场景像产线 → 偏 B
|
||||||
|
when_all:
|
||||||
|
- type: slot_contains
|
||||||
|
slot: scenario
|
||||||
|
value: 产线
|
||||||
|
then:
|
||||||
|
product_bundle: ["XJ-B200", "XJ-B-IO"]
|
||||||
|
tech_line: B类控制(场景推断)
|
||||||
|
summary: 根据产线场景初步偏向 B 类控制方案,建议人工确认指标后锁定型号。
|
||||||
|
confidence: 0.68
|
||||||
|
|
||||||
|
- id: scenario_outdoor
|
||||||
|
order: 40
|
||||||
|
description: 户外监测 → 偏 A
|
||||||
|
when_all:
|
||||||
|
- type: slot_contains
|
||||||
|
slot: scenario
|
||||||
|
value: 户外
|
||||||
|
then:
|
||||||
|
product_bundle: ["XJ-A100", "XJ-A-GW"]
|
||||||
|
tech_line: A类传感(场景推断)
|
||||||
|
summary: 根据户外监测场景初步偏向 A 类传感方案,需确认防护与供电约束。
|
||||||
|
confidence: 0.66
|
||||||
|
|
||||||
|
- id: fallback_uncertain
|
||||||
|
order: 1000
|
||||||
|
description: 兜底:信息不足或不确定
|
||||||
|
when_all:
|
||||||
|
- type: always
|
||||||
|
then:
|
||||||
|
product_bundle: ["待专家确认"]
|
||||||
|
tech_line: 待判定
|
||||||
|
summary: 当前信息尚不足以稳定定型,建议补充指标或转人工销售确认。
|
||||||
|
confidence: 0.4
|
||||||
|
handoff: true
|
||||||
|
|
||||||
|
# 低于该置信度则建议转人工
|
||||||
|
handoff_confidence_below: 0.55
|
||||||
@@ -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: [通用, 约束]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 追问槽位:需求未定稿时可随时增删字段;引擎按 priority 顺序问未填项
|
||||||
|
version: 1
|
||||||
|
intent: solution_consult
|
||||||
|
|
||||||
|
slots:
|
||||||
|
- id: tech_category
|
||||||
|
label: 技术大类倾向
|
||||||
|
prompt: 目前更倾向哪一类技术路线?也可以说「不确定,希望你们推荐」。(示例:A类传感 / B类控制 / 不确定)
|
||||||
|
required: true
|
||||||
|
priority: 10
|
||||||
|
extract_hint: 用户提到的技术大类或不确定
|
||||||
|
|
||||||
|
- id: scenario
|
||||||
|
label: 应用场景
|
||||||
|
prompt: 客户主要用在什么场景或工况?(例如产线检测、户外监测、实验室标定等)
|
||||||
|
required: true
|
||||||
|
priority: 20
|
||||||
|
|
||||||
|
- id: key_metric
|
||||||
|
label: 关键指标
|
||||||
|
prompt: 有没有必须满足的关键指标或参数范围?(量程、精度、响应时间等,没有可以说「暂无」)
|
||||||
|
required: true
|
||||||
|
priority: 30
|
||||||
|
|
||||||
|
- id: constraints
|
||||||
|
label: 现场约束
|
||||||
|
prompt: 现场有哪些约束?(空间、已有设备、预算区间、交付区域等,没有可说「暂无」)
|
||||||
|
required: false
|
||||||
|
priority: 40
|
||||||
|
|
||||||
|
closing_prompt: 信息已较完整,正在为您匹配方案…
|
||||||
|
handoff_phrases:
|
||||||
|
- 转人工
|
||||||
|
- 人工客服
|
||||||
|
- 找销售
|
||||||
|
- 真人
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user