feat: 可配置方案引擎通用原型(Mock 检索)

需求未定稿时可先跑通追问/判定/出方案;槽位与判定树外置 YAML,后续可接 Ragflow。

Co-authored-by: haijd <haijd@163.com>
This commit is contained in:
Cursor Agent
2026-09-18 09:12:18 +00:00
co-authored by haijd
parent 93b0867eb3
commit 9c6afcb61a
24 changed files with 978 additions and 24 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
.venv/
venv/
.env
*.pyc
.DS_Store
+38 -24
View File
@@ -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,而不是推倒重来
+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、薄后台
不要颠倒:先做大后台或双渠道。
+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,
}
+75
View File
@@ -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
+94
View File
@@ -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)
+113
View File
@@ -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,
}
+115
View File
@@ -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"]}
+22
View File
@@ -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()
+73
View File
@@ -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试试:「我们在户外做监测,指标还不确定」。");
+43
View File
@@ -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>
+144
View File
@@ -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;
}
+7
View File
@@ -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
+71
View File
@@ -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
+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: 目前更倾向哪一类技术路线?也可以说「不确定,希望你们推荐」。(示例: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.