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。

+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + diff --git a/engine/app/static/styles.css b/engine/app/static/styles.css new file mode 100644 index 0000000..0e986ca --- /dev/null +++ b/engine/app/static/styles.css @@ -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; +} diff --git a/engine/requirements.txt b/engine/requirements.txt new file mode 100644 index 0000000..c727224 --- /dev/null +++ b/engine/requirements.txt @@ -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 diff --git a/knowledge/decision_tree.yaml b/knowledge/decision_tree.yaml new file mode 100644 index 0000000..031eb48 --- /dev/null +++ b/knowledge/decision_tree.yaml @@ -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 diff --git a/knowledge/sample_chunks.yaml b/knowledge/sample_chunks.yaml new file mode 100644 index 0000000..c30ae75 --- /dev/null +++ b/knowledge/sample_chunks.yaml @@ -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: [通用, 约束] diff --git a/knowledge/slots.yaml b/knowledge/slots.yaml new file mode 100644 index 0000000..e1241d2 --- /dev/null +++ b/knowledge/slots.yaml @@ -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: + - 转人工 + - 人工客服 + - 找销售 + - 真人 diff --git a/uploads/__3_e172.docx b/uploads/__3_e172.docx new file mode 100644 index 0000000..34e5f32 Binary files /dev/null and b/uploads/__3_e172.docx differ diff --git a/uploads/__AI____________V1_8911.docx b/uploads/__AI____________V1_8911.docx new file mode 100644 index 0000000..9693d8d Binary files /dev/null and b/uploads/__AI____________V1_8911.docx differ diff --git a/uploads/_____AI_____________142b.pdf b/uploads/_____AI_____________142b.pdf new file mode 100644 index 0000000..55461ee Binary files /dev/null and b/uploads/_____AI_____________142b.pdf differ diff --git a/uploads/__________AI________________6571.pdf b/uploads/__________AI________________6571.pdf new file mode 100644 index 0000000..45558f6 Binary files /dev/null and b/uploads/__________AI________________6571.pdf differ diff --git a/uploads/___________2026V1_60e9.docx b/uploads/___________2026V1_60e9.docx new file mode 100644 index 0000000..bf7055d Binary files /dev/null and b/uploads/___________2026V1_60e9.docx differ