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 万** 硬顶) |
|
||||
| 纯 SaaS(扣子 / 阿里云客服等) | 不推荐(数据自主可控冲突) |
|
||||
| **Dify 自托管二开 + 方案判定定制** | **推荐** |
|
||||
| `engine/` | FastAPI 方案引擎 + 简易聊天页 |
|
||||
| `knowledge/` | `slots.yaml` / `decision_tree.yaml` / 示例检索片段 |
|
||||
| `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/)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [docs/技术方案建议书.md](./docs/技术方案建议书.md) | 自研/二开决策、Dify vs Ragflow、许可 |
|
||||
| [docs/Ragflow实施方案.md](./docs/Ragflow实施方案.md) | **选定 Ragflow 后怎么搭**(架构、知识库、方案引擎、分期) |
|
||||
| [uploads/](./uploads/) | 原始合同、进度表、建议书、验收标准 |
|
||||
健康检查:`GET /api/health`
|
||||
重载业务配置:`POST /api/admin/reload-config`(改完 YAML 不用重启进程时用)
|
||||
|
||||
## 若已定 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 是否可上生产 / 是否必须私有化
|
||||
4. 场景二(录音辅助)是否与场景一同期验收
|
||||
5. 部署:甲方机房还是乙方 VPC
|
||||
## 需求清晰后怎么补
|
||||
|
||||
1. 改 `knowledge/slots.yaml`(追问项)
|
||||
2. 改 `knowledge/decision_tree.yaml`(推荐规则)
|
||||
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