diff --git a/.env.example b/.env.example
index f85b6e9..1d7216f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,11 @@
-# 复制为 .env 后重启服务。留空则只做检索,回答直接摘自最相关切片。
-OPENAI_API_KEY=
-OPENAI_BASE_URL=https://api.openai.com/v1
-OPENAI_MODEL=gpt-4o-mini
+# 回答由局域网 Ollama 生成。改地址或模型名后需要重启服务。
+OLLAMA_BASE_URL=http://10.7.9.3:11434
+OLLAMA_MODEL=qwen3-vl:4b
+
+# 如果改用 OpenAI 兼容接口,同时填上这两项后会优先使用它。
+# OPENAI_API_KEY=
+# OPENAI_BASE_URL=https://api.openai.com/v1
+# OPENAI_MODEL=gpt-4o-mini
# 可选。嵌入模型默认从 Hugging Face 下载,连不上时会自动改用镜像。
# HF_ENDPOINT=https://hf-mirror.com
diff --git a/app/config.py b/app/config.py
index e2a443f..42a3d16 100644
--- a/app/config.py
+++ b/app/config.py
@@ -36,12 +36,13 @@ load_dotenv()
def llm_config() -> dict | None:
key = os.getenv("OPENAI_API_KEY", "").strip()
- if not key:
+ openai_base = os.getenv("OPENAI_BASE_URL", "").strip()
+ if key and openai_base:
+ model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip() or "gpt-4o-mini"
+ return {"provider": "openai", "key": key, "base": openai_base.rstrip("/"), "model": model}
+
+ base = os.getenv("OLLAMA_BASE_URL", "http://10.7.9.3:11434").strip()
+ if not base:
return None
- base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").strip()
- model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip()
- return {
- "key": key,
- "base": (base or "https://api.openai.com/v1").rstrip("/"),
- "model": model or "gpt-4o-mini",
- }
+ model = os.getenv("OLLAMA_MODEL", "qwen3-vl:4b").strip() or "qwen3-vl:4b"
+ return {"provider": "ollama", "key": "ollama", "base": base.rstrip("/"), "model": model}
diff --git a/app/generator.py b/app/generator.py
index 169b7c5..79dd230 100644
--- a/app/generator.py
+++ b/app/generator.py
@@ -1,4 +1,5 @@
import logging
+import re
import httpx
@@ -6,10 +7,15 @@ from app.config import LOW_SCORE, llm_config
logger = logging.getLogger("learn_rag")
-SYSTEM_PROMPT = """你是「知迹」的知识库问答助手。只能根据给出的知识切片回答,不要补充切片之外的信息,也不要编造。
-如果切片不足以回答,就说明知识库里没有这项信息。
-用自然的中文直接回答问题,不要使用 Markdown,不要写标题。
-引用事实时在相关句末标注编号,格式必须是〔来源1〕这种全角括号。编号只能使用给定切片的编号。"""
+# 发给服务器的第一条消息,role = system。
+# 这里只有客服身份和回答规则,不含检索结果。换模型时这段一般不用改。
+SYSTEM_PROMPT = """你是「知迹」的客服。用友好、耐心、自然的中文和顾客说话,像在柜台前当面回答。
+规则:
+1. 只能根据本次给出的知识切片回答,不要补充切片之外的信息,不要编造。
+2. 先用「您好」一类的问候开场,再回答问题,并补上顾客需要知道的条件。语气像柜台客服,礼貌、清楚,不要只报两个数字。
+3. 如果切片不够回答,坦诚说明现在查不到,并建议对方到服务台再确认。
+4. 不要使用 Markdown,不要写标题,不要复述全部切片。
+5. 引用事实时在相关句末标注〔来源1〕这种编号。编号只能使用给定切片的编号。"""
def build_context(chunks: list[dict]) -> str:
@@ -46,8 +52,23 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b
caution = ""
if low_confidence:
- caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请明确说知识库里没有这项信息。\n\n"
- user_prompt = f"{caution}知识切片如下:\n\n{build_context(chunks)}\n\n问题:{question}"
+ caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请坦诚告诉顾客查不到。\n\n"
+ # 发给服务器的第二条消息,role = user。检索到的切片从这里进提示词。
+ # 拼出来的样子:
+ # 下面是检索到的知识切片,请据此回答顾客。
+ #
+ # 〔来源1〕《文档标题》相关度 75%
+ # 切片原文……
+ #
+ # 〔来源2〕《文档标题》相关度 71%
+ # 切片原文……
+ #
+ # 顾客的问题:柏卡会员年费多少,能借几本书?
+ user_prompt = (
+ f"{caution}下面是检索到的知识切片,请据此回答顾客。\n\n"
+ f"{build_context(chunks)}\n\n"
+ f"顾客的问题:{question}"
+ )
try:
answer = call_llm(settings, user_prompt)
except Exception:
@@ -57,29 +78,56 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b
return answer, "llm", None, low_confidence
+def strip_think(text: str) -> str:
+ return re.sub(r"