diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48c4f6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.venv/ +venv/ +.env +*.pyc +.DS_Store diff --git a/README.md b/README.md index 90e70a5..b0f649d 100644 --- a/README.md +++ b/README.md @@ -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,而不是推倒重来 diff --git a/docs/原型搭建步骤.md b/docs/原型搭建步骤.md new file mode 100644 index 0000000..8da5660 --- /dev/null +++ b/docs/原型搭建步骤.md @@ -0,0 +1,18 @@ +# 需求未定稿时:先搭通用原型的步骤 + +## 要不要先搭原型? + +**要。** 做成「配置驱动的通用骨架」,不要等需求 100% 精确再动手。 + +- 先验证:追问 → 判定 → 检索引用 → 出方案草稿 +- 业务变了:主要改 `knowledge/*.yaml` +- 后台/渠道:闭环跑通后再补 + +## 具体步骤 + +1. **拉起本原型**(不必先装 Ragflow) +2. **和业务改槽位/判定树 YAML**(边聊边改,`POST /api/admin/reload-config`) +3. **灌真实小样本到 Ragflow**,切 `RETRIEVAL_BACKEND=ragflow` +4. **再补**转人工、单渠道、Eval、薄后台 + +不要颠倒:先做大后台或双渠道。 diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..1ff1eee --- /dev/null +++ b/engine/__init__.py @@ -0,0 +1 @@ +# Package diff --git a/engine/app/__init__.py b/engine/app/__init__.py new file mode 100644 index 0000000..0e632e1 --- /dev/null +++ b/engine/app/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/engine/app/config_loader.py b/engine/app/config_loader.py new file mode 100644 index 0000000..9ba3647 --- /dev/null +++ b/engine/app/config_loader.py @@ -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() diff --git a/engine/app/decision.py b/engine/app/decision.py new file mode 100644 index 0000000..2571df1 --- /dev/null +++ b/engine/app/decision.py @@ -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, + } diff --git a/engine/app/main.py b/engine/app/main.py new file mode 100644 index 0000000..e4b3b90 --- /dev/null +++ b/engine/app/main.py @@ -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 diff --git a/engine/app/retrieval.py b/engine/app/retrieval.py new file mode 100644 index 0000000..95214f9 --- /dev/null +++ b/engine/app/retrieval.py @@ -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) diff --git a/engine/app/scheme_engine.py b/engine/app/scheme_engine.py new file mode 100644 index 0000000..edf6666 --- /dev/null +++ b/engine/app/scheme_engine.py @@ -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, + } diff --git a/engine/app/session_store.py b/engine/app/session_store.py new file mode 100644 index 0000000..6b4aa2b --- /dev/null +++ b/engine/app/session_store.py @@ -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"]} diff --git a/engine/app/settings.py b/engine/app/settings.py new file mode 100644 index 0000000..fe97c68 --- /dev/null +++ b/engine/app/settings.py @@ -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() diff --git a/engine/app/static/app.js b/engine/app/static/app.js new file mode 100644 index 0000000..0442152 --- /dev/null +++ b/engine/app/static/app.js @@ -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试试:「我们在户外做监测,指标还不确定」。"); diff --git a/engine/app/static/index.html b/engine/app/static/index.html new file mode 100644 index 0000000..e9d2d4c --- /dev/null +++ b/engine/app/static/index.html @@ -0,0 +1,43 @@ + + +
+ + +鲜境物联 · 方案引擎原型
+槽位与判定树在 knowledge/*.yaml,需求变了改配置即可。当前检索为 Mock,可后再接 Ragflow。