初始提交:识流 AI 助手项目
微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。 技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite 注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
0
app/infrastructure/service/backend/__init__.py
Normal file
0
app/infrastructure/service/backend/__init__.py
Normal file
81
app/infrastructure/service/backend/ai.py
Normal file
81
app/infrastructure/service/backend/ai.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import requests
|
||||
|
||||
from app.infrastructure.service.backend.config import AI_PROVIDER, DEEPSEEK_API_BASE, DEEPSEEK_API_KEY, DEEPSEEK_MODEL, DIFY_API_BASE, DIFY_API_KEY, DIFY_USER, OPENAI_API_BASE, OPENAI_API_KEY, OPENAI_MODEL
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
|
||||
def do_openai_like(url, headers, payload):
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
data = resp.json() if resp.text else {}
|
||||
if resp.status_code >= 400:
|
||||
return f"抱歉,AI 服务请求失败({resp.status_code})"
|
||||
content = (((data or {}).get("choices") or [{}])[0].get("message") or {}).get("content", "")
|
||||
return content.strip() if content else "抱歉,AI 暂时没有合理的回复。"
|
||||
except Exception:
|
||||
return "抱歉,AI 服务暂时不可用,请稍后再试。"
|
||||
|
||||
|
||||
def do_dify(url, headers, payload):
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
text = resp.text or ""
|
||||
if "data:" in text:
|
||||
answer = ""
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
chunk = line[5:].strip()
|
||||
if not chunk or chunk == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
j = requests.models.complexjson.loads(chunk)
|
||||
except Exception:
|
||||
continue
|
||||
if "answer" in j:
|
||||
answer += j["answer"]
|
||||
if answer.strip():
|
||||
return answer.strip()
|
||||
data = resp.json() if resp.text else {}
|
||||
if resp.status_code >= 400:
|
||||
return f"抱歉,Dify 服务请求失败({resp.status_code})"
|
||||
return (data.get("answer") or "抱歉,Dify 暂时没有合理的回复。").strip()
|
||||
except Exception:
|
||||
return "抱歉,Dify 服务暂时不可用,请稍后再试。"
|
||||
|
||||
|
||||
def call_ai(prompt, user_id=""):
|
||||
trace_id = new_trace_id("ai")
|
||||
provider = AI_PROVIDER
|
||||
log_event("INFO", "ai", "ai.request", trace_id, "request", "ok", "发起AI请求", extra={"provider": provider, "user_id": user_id or "", "prompt_len": len(prompt or "")})
|
||||
if provider == "mock":
|
||||
result = "【自动回复】你刚才说了:" + (prompt or "")[:100]
|
||||
log_event("INFO", "ai", "ai.response", trace_id, "response", "ok", "AI回复完成", extra={"provider": provider, "reply_len": len(result)})
|
||||
return result
|
||||
if provider == "openai":
|
||||
result = do_openai_like(
|
||||
OPENAI_API_BASE.rstrip("/") + "/chat/completions",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {OPENAI_API_KEY}"},
|
||||
{"model": OPENAI_MODEL, "messages": [{"role": "system", "content": "你是一个专业的微信私域运营助手,用简洁自然的中文回复用户。"}, {"role": "user", "content": prompt}], "temperature": 0.7, "user": user_id or None},
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
result = do_openai_like(
|
||||
DEEPSEEK_API_BASE.rstrip("/") + "/chat/completions",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {DEEPSEEK_API_KEY}"},
|
||||
{"model": DEEPSEEK_MODEL, "messages": [{"role": "system", "content": "你是一个简洁高效的微信助手。回复要求:一句话,不超过50字。"}, {"role": "user", "content": prompt}], "temperature": 0.7, "max_tokens": 100, "user": user_id or None},
|
||||
)
|
||||
elif provider == "dify":
|
||||
result = do_dify(
|
||||
DIFY_API_BASE.rstrip("/") + "/chat-messages",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {DIFY_API_KEY}"},
|
||||
{"inputs": {}, "query": prompt, "response_mode": "streaming", "user": user_id or DIFY_USER, "conversation_id": ""},
|
||||
)
|
||||
else:
|
||||
result = "AI_PROVIDER 未配置正确,请检查环境变量。"
|
||||
|
||||
if result.startswith("抱歉") or "未配置正确" in result:
|
||||
log_event("WARNING", "ai", "ai.response", trace_id, "response", "failed", "AI回复异常或降级", reason="provider_error", extra={"provider": provider, "reply": result[:120]})
|
||||
else:
|
||||
log_event("INFO", "ai", "ai.response", trace_id, "response", "ok", "AI回复完成", extra={"provider": provider, "reply_len": len(result)})
|
||||
return result
|
||||
45
app/infrastructure/service/backend/config.py
Normal file
45
app/infrastructure/service/backend/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from app.configs.runtime_config import get_int, get_str
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
ASSETS_DIR = PROJECT_ROOT / "assets"
|
||||
FRONTEND_DIST_DIR = PROJECT_ROOT / "frontend" / "dist"
|
||||
_frontend_static_dir = get_str("FRONTEND_STATIC_DIR", "").strip()
|
||||
FRONTEND_STATIC_DIR = Path(_frontend_static_dir).resolve() if _frontend_static_dir else None
|
||||
STATIC_DIR = FRONTEND_STATIC_DIR or (FRONTEND_DIST_DIR if FRONTEND_DIST_DIR.exists() else ASSETS_DIR)
|
||||
|
||||
DB_HOST = get_str("DB_HOST", "127.0.0.1")
|
||||
DB_PORT = get_int("DB_PORT", 3306)
|
||||
DB_NAME = get_str("DB_NAME", "ai_shiliu")
|
||||
DB_USER = get_str("DB_USER", "ai_shiliu")
|
||||
DB_PASS = get_str("DB_PASS", "")
|
||||
DB_CHARSET = get_str("DB_CHARSET", "utf8mb4")
|
||||
|
||||
AI_PROVIDER = get_str("AI_PROVIDER", "")
|
||||
OPENAI_API_KEY = get_str("OPENAI_API_KEY", "")
|
||||
OPENAI_API_BASE = get_str("OPENAI_API_BASE", "")
|
||||
OPENAI_MODEL = get_str("OPENAI_MODEL", "")
|
||||
DEEPSEEK_API_KEY = get_str("DEEPSEEK_API_KEY", "")
|
||||
DEEPSEEK_API_BASE = get_str("DEEPSEEK_API_BASE", "")
|
||||
DEEPSEEK_MODEL = get_str("DEEPSEEK_MODEL", "")
|
||||
DIFY_API_KEY = get_str("DIFY_API_KEY", "")
|
||||
DIFY_API_BASE = get_str("DIFY_API_BASE", "")
|
||||
DIFY_USER = get_str("DIFY_USER", "")
|
||||
|
||||
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="")
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_cors_headers(response):
|
||||
origin = (request.headers.get("Origin") or "").strip()
|
||||
allow_origin = origin or "*"
|
||||
response.headers["Access-Control-Allow-Origin"] = allow_origin
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return response
|
||||
218
app/infrastructure/service/backend/db.py
Normal file
218
app/infrastructure/service/backend/db.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from app.infrastructure.service.backend.config import PROJECT_ROOT
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
_SETTING_CACHE = {}
|
||||
SETTINGS_FILE = PROJECT_ROOT / "logs" / "state" / "local_settings.json"
|
||||
LOCAL_APPDATA_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
||||
SQLITE_DB_PATH = LOCAL_APPDATA_DIR / "com.shiliu.aiassistant" / "ai_shiliu.sqlite3"
|
||||
|
||||
|
||||
class _SQLiteCursor:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self._cursor.close()
|
||||
|
||||
@staticmethod
|
||||
def _adapt_sql(sql: str) -> str:
|
||||
return sql.replace("%s", "?")
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
sql = self._adapt_sql(sql)
|
||||
if params is None:
|
||||
self._cursor.execute(sql)
|
||||
else:
|
||||
self._cursor.execute(sql, params)
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
row = self._cursor.fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
def fetchall(self):
|
||||
return [dict(row) for row in self._cursor.fetchall()]
|
||||
|
||||
@property
|
||||
def lastrowid(self):
|
||||
return self._cursor.lastrowid
|
||||
|
||||
|
||||
class _SQLiteConn:
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def cursor(self):
|
||||
return _SQLiteCursor(self._conn.cursor())
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _bootstrap_sqlite_file():
|
||||
SQLITE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_conn(db_name=None):
|
||||
_bootstrap_sqlite_file()
|
||||
conn = sqlite3.connect(str(SQLITE_DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return _SQLiteConn(conn)
|
||||
|
||||
|
||||
def init_db():
|
||||
trace_id = new_trace_id("db")
|
||||
log_event("INFO", "db", "db.init", trace_id, "start", "ok", "初始化数据库开始", extra={"path": str(SQLITE_DB_PATH)})
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wx_user_id TEXT NOT NULL DEFAULT '',
|
||||
wx_nickname TEXT NOT NULL DEFAULT '',
|
||||
direction TEXT NOT NULL DEFAULT 'in',
|
||||
content TEXT NOT NULL,
|
||||
is_ai_reply INTEGER NOT NULL DEFAULT 0,
|
||||
rule_id INTEGER NULL,
|
||||
is_friend_request INTEGER NOT NULL DEFAULT 0,
|
||||
reply_strategy TEXT NOT NULL DEFAULT '',
|
||||
reply_reason TEXT NOT NULL DEFAULT '',
|
||||
ocr_confidence TEXT NOT NULL DEFAULT '',
|
||||
ocr_bubble_side TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_messages_user_time ON messages(wx_user_id, created_at)")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'contain',
|
||||
reply_text TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
cols = {str(x.get('name') or '') for x in (cur.fetchall() or [])}
|
||||
if "reply_strategy" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN reply_strategy TEXT NOT NULL DEFAULT ''")
|
||||
if "reply_reason" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN reply_reason TEXT NOT NULL DEFAULT ''")
|
||||
if "ocr_confidence" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN ocr_confidence TEXT NOT NULL DEFAULT ''")
|
||||
if "ocr_bubble_side" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN ocr_bubble_side TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
conn.commit()
|
||||
log_event("INFO", "db", "db.init", trace_id, "done", "ok", "初始化数据库完成")
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
log_event("ERROR", "db", "db.init", trace_id, "done", "failed", "初始化数据库失败", reason="db_error", extra={"error": str(exc)})
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_settings_file():
|
||||
if _SETTING_CACHE:
|
||||
return
|
||||
try:
|
||||
if SETTINGS_FILE.exists():
|
||||
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
_SETTING_CACHE[str(k)] = str(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _save_settings_file():
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(_SETTING_CACHE, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def get_setting(key, default=None):
|
||||
_load_settings_file()
|
||||
if key in _SETTING_CACHE:
|
||||
return _SETTING_CACHE[key]
|
||||
if default is None:
|
||||
return None
|
||||
val = str(default)
|
||||
_SETTING_CACHE[key] = val
|
||||
_save_settings_file()
|
||||
return val
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
_load_settings_file()
|
||||
_SETTING_CACHE[str(key)] = str(value)
|
||||
_save_settings_file()
|
||||
|
||||
|
||||
def normalize_text(text):
|
||||
t = (text or "").strip().lower()
|
||||
t = re.sub(r"\s+", "", t)
|
||||
t = t.replace(":", ":")
|
||||
return t
|
||||
|
||||
|
||||
def find_rule_reply(content):
|
||||
trace_id = new_trace_id("db")
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM auto_reply_rules WHERE is_active = 1 ORDER BY id ASC")
|
||||
rules = cur.fetchall()
|
||||
except Exception as exc:
|
||||
log_event("ERROR", "db", "db.rule.query", trace_id, "query", "failed", "查询规则失败", reason="db_error", extra={"error": str(exc)})
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
raw_content = (content or "").strip()
|
||||
content_lower = raw_content.lower()
|
||||
content_norm = normalize_text(raw_content)
|
||||
|
||||
for rule in rules:
|
||||
kw = (rule.get("keyword") or "").strip()
|
||||
if not kw:
|
||||
continue
|
||||
|
||||
kw_lower = kw.lower()
|
||||
kw_norm = normalize_text(kw)
|
||||
match_type = rule.get("match_type")
|
||||
|
||||
if match_type == "equal":
|
||||
if content_lower == kw_lower or content_norm == kw_norm:
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "命中规则", reason="rule_hit", extra={"rule_id": rule.get("id"), "match_type": match_type})
|
||||
return rule
|
||||
else:
|
||||
if kw_lower in content_lower or kw_norm in content_norm:
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "命中规则", reason="rule_hit", extra={"rule_id": rule.get("id"), "match_type": match_type or "contain"})
|
||||
return rule
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "未命中规则", reason="rule_miss", extra={"rule_count": len(rules)})
|
||||
return None
|
||||
Reference in New Issue
Block a user