add files
This commit is contained in:
1 parent
ab8adbfa12
commit
fbb31409da
8 files changed
+775
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
# Zalo Bot token (bắt buộc)
|
||||||
|
BOT_TOKEN=your_zalo_bot_token
|
||||||
|
|
||||||
|
# Hermes API Server (gateway) — OpenAI-compatible, full agent tools
|
||||||
|
# Bật: API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env rồi restart hermes-gateway
|
||||||
|
HERMES_API_BASE=http://127.0.0.1:8642/v1
|
||||||
|
HERMES_API_KEY=change-me-local-dev
|
||||||
|
HERMES_MODEL=hermes-agent
|
||||||
|
HERMES_TIMEOUT=180
|
||||||
|
|
||||||
|
# Giữ bao nhiêu lượt user/assistant trong session Zalo → Hermes
|
||||||
|
HERMES_HISTORY_TURNS=12
|
||||||
|
|
||||||
|
# System prompt gắn vào mỗi request (tuỳ chọn)
|
||||||
|
HERMES_SYSTEM_PROMPT=Bạn đang chat với người dùng qua Zalo Bot. Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. Không dùng markdown nặng (**, ##, [[wiki]]); ưu tiên plain text dễ đọc trên Zalo.
|
||||||
|
|
||||||
|
# Backend: api (mặc định, API Server) | cli (hermes chat -q, chậm hơn, không giữ history tốt)
|
||||||
|
HERMES_BACKEND=api
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
# Debug json
|
||||||
|
*.json
|
||||||
|
data/
|
||||||
|
chroma_db/
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
#poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
#pdm.lock
|
||||||
|
#pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
#pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||||
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
|
.cursorignore
|
||||||
|
.cursorindexingignore
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Zalo Bot → Hermes Agent (API Server). Không dùng support KB :8766."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load .env TRƯỚC mọi import đọc os.environ (hermes_client / BOT_TOKEN)
|
||||||
|
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||||
|
|
||||||
|
import zalo_bot
|
||||||
|
|
||||||
|
from format_zalo import format_for_zalo
|
||||||
|
from hermes_client import (
|
||||||
|
HERMES_API_BASE,
|
||||||
|
HERMES_BACKEND,
|
||||||
|
ask_hermes,
|
||||||
|
clear_history,
|
||||||
|
hermes_health,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.getLogger("zalo_bot").setLevel(logging.ERROR)
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
raise ValueError("BOT_TOKEN not found in .env")
|
||||||
|
|
||||||
|
_HERMES_WORKERS = int(os.getenv("HERMES_WORKERS", "2"))
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=max(1, _HERMES_WORKERS))
|
||||||
|
_busy: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def split_zalo_text(text: str, limit: int = 1800) -> list[str]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ["(không có nội dung)"]
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
parts: list[str] = []
|
||||||
|
rest = text
|
||||||
|
while rest:
|
||||||
|
if len(rest) <= limit:
|
||||||
|
parts.append(rest)
|
||||||
|
break
|
||||||
|
cut = rest.rfind("\n", 0, limit)
|
||||||
|
if cut < limit // 3:
|
||||||
|
cut = rest.rfind(" ", 0, limit)
|
||||||
|
if cut < limit // 3:
|
||||||
|
cut = limit
|
||||||
|
parts.append(rest[:cut].rstrip())
|
||||||
|
rest = rest[cut:].lstrip()
|
||||||
|
return parts or [text[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def get_answer(chat_id: str, question: str) -> str:
|
||||||
|
q = (question or "").strip()
|
||||||
|
if not q:
|
||||||
|
return "Bạn vui lòng gửi nội dung cần hỏi nhé."
|
||||||
|
|
||||||
|
if q.lower() in {"/reset", "/new", "reset", "xóa lịch sử", "xoa lich su"}:
|
||||||
|
clear_history(chat_id)
|
||||||
|
return "Đã xóa lịch sử hội thoại với Hermes cho chat này."
|
||||||
|
|
||||||
|
result = ask_hermes(chat_id, q)
|
||||||
|
answer = (result.get("answer") or "").strip()
|
||||||
|
plain = format_for_zalo(answer)
|
||||||
|
return plain or answer
|
||||||
|
|
||||||
|
|
||||||
|
async def send_long_message(bot: zalo_bot.Bot, chat_id: str, text: str) -> None:
|
||||||
|
for part in split_zalo_text(text):
|
||||||
|
await bot.send_message(chat_id, part)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_user_message(bot: zalo_bot.Bot, chat_id: str, question: str) -> None:
|
||||||
|
"""Chạy nền: Hermes → trả Zalo (không chặn long-poll)."""
|
||||||
|
key = str(chat_id)
|
||||||
|
if key in _busy:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id,
|
||||||
|
"Mình đang xử lý câu trước của bạn. Vui lòng đợi một chút…",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_busy.add(key)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await bot.send_chat_action(chat_id, "typing")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
answer = await loop.run_in_executor(_executor, get_answer, chat_id, question)
|
||||||
|
await send_long_message(bot, chat_id, answer)
|
||||||
|
print(f"[zalo] Sent to {chat_id} ({len(answer)} chars)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[zalo] handle error for {chat_id}: {e}")
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id,
|
||||||
|
"Có lỗi khi nhờ Hermes trả lời. Bạn thử lại sau nhé.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_busy.discard(key)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
health = hermes_health()
|
||||||
|
print(f"[hermes] backend={HERMES_BACKEND} base={HERMES_API_BASE} health={health}")
|
||||||
|
if not health.get("ok"):
|
||||||
|
print(
|
||||||
|
"[hermes] WARNING: API Server chưa sẵn sàng. "
|
||||||
|
"Bật API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env "
|
||||||
|
"rồi systemctl --user restart hermes-gateway "
|
||||||
|
"(listen http://127.0.0.1:8642)."
|
||||||
|
)
|
||||||
|
|
||||||
|
bot = zalo_bot.Bot(BOT_TOKEN)
|
||||||
|
async with bot:
|
||||||
|
me = await bot.get_me()
|
||||||
|
print(
|
||||||
|
f"[zalo] started as {getattr(me, 'account_name', None) or me} "
|
||||||
|
f"id={getattr(me, 'id', None)}; listening…"
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
update = await bot.get_update(timeout=30)
|
||||||
|
if not (update and update.message and update.message.text):
|
||||||
|
continue
|
||||||
|
|
||||||
|
chat_id = update.message.chat.id
|
||||||
|
question = update.message.text
|
||||||
|
print(f"[zalo] Message from {chat_id}: {question}")
|
||||||
|
asyncio.create_task(handle_user_message(bot, chat_id, question))
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise
|
||||||
|
except zalo_bot.error.ZaloError:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[zalo] Unexpected error: {e}")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[zalo] stopped (Ctrl+C)")
|
||||||
|
finally:
|
||||||
|
_executor.shutdown(wait=False, cancel_futures=True)
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
"""Chuẩn hóa câu trả lời Hermes → text thuần cho Zalo (không markdown)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
_MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||||
|
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
||||||
|
_ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
|
||||||
|
_STRIKE = re.compile(r"~~([^~]+)~~")
|
||||||
|
_CODE_FENCE = re.compile(r"```[\s\S]*?```")
|
||||||
|
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
||||||
|
_HEADING = re.compile(r"(?m)^#{1,6}\s+")
|
||||||
|
_BULLET = re.compile(r"(?m)^[\-\*]\s+")
|
||||||
|
_ORDERED = re.compile(r"(?m)^\d+\.\s+")
|
||||||
|
_BLOCKQUOTE = re.compile(r"(?m)^>\s?")
|
||||||
|
_HR = re.compile(r"(?m)^(?:-{3,}|\*{3,}|_{3,})\s*$")
|
||||||
|
_HTML_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
|
||||||
|
_WIKI_OR_REF = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]")
|
||||||
|
_MULTI_NL = re.compile(r"\n{3,}")
|
||||||
|
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
|
||||||
|
_DASHY = re.compile(r"\s*[—–]\s*")
|
||||||
|
|
||||||
|
# Lỗi chính tả / khoảng trắng tiếng Việt thường gặp từ model
|
||||||
|
_TYPO_FIXES: list[tuple[re.Pattern[str], str]] = [
|
||||||
|
(re.compile(r"\s+([,.!?;:…])"), r"\1"),
|
||||||
|
(re.compile(r"([(\[])\s+"), r"\1"),
|
||||||
|
(re.compile(r"\s+([)\]])"), r"\1"),
|
||||||
|
(re.compile(r"(?i)\bko\b"), "không"),
|
||||||
|
(re.compile(r"(?i)\bdc\b"), "được"),
|
||||||
|
(re.compile(r"(?i)\bđc\b"), "được"),
|
||||||
|
(re.compile(r"(?i)\bkq\b"), "kết quả"),
|
||||||
|
(re.compile(r"(?i)\bntn\b"), "như thế nào"),
|
||||||
|
(re.compile(r"(?i)\bntn\b"), "như thế nào"),
|
||||||
|
(re.compile(r"(?i)\bmn\b"), "mọi người"),
|
||||||
|
(re.compile(r"(?i)\bbj\b"), "bây giờ"),
|
||||||
|
(re.compile(r"(?i)\bhqua\b"), "hôm qua"),
|
||||||
|
(re.compile(r"(?i)\bhnay\b"), "hôm nay"),
|
||||||
|
(re.compile(r"(?i)\bsdt\b"), "SĐT"),
|
||||||
|
(re.compile(r"(?i)\bvs\b"), "với"),
|
||||||
|
(re.compile(r"\u00a0"), " "),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _nfc(text: str) -> str:
|
||||||
|
return unicodedata.normalize("NFC", text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_markdown(text: str) -> str:
|
||||||
|
t = text
|
||||||
|
t = _CODE_FENCE.sub(lambda m: "\n".join(
|
||||||
|
line for line in m.group(0).splitlines()[1:-1]
|
||||||
|
), t)
|
||||||
|
t = _HTML_TAG.sub("", t)
|
||||||
|
t = _WIKI_OR_REF.sub(lambda m: (m.group(2) or m.group(1) or "").strip(), t)
|
||||||
|
t = _MD_LINK.sub(lambda m: m.group(1).strip() or m.group(2).strip(), t)
|
||||||
|
t = _BOLD.sub(r"\1", t)
|
||||||
|
t = _ITALIC.sub(r"\1", t)
|
||||||
|
t = _STRIKE.sub(r"\1", t)
|
||||||
|
t = _INLINE_CODE.sub(r"\1", t)
|
||||||
|
t = _HEADING.sub("", t)
|
||||||
|
t = _BLOCKQUOTE.sub("", t)
|
||||||
|
t = _HR.sub("", t)
|
||||||
|
t = _BULLET.sub("• ", t)
|
||||||
|
t = _ORDERED.sub(lambda m: f"{m.group(0).split('.')[0]}. ", t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_vietnamese_typos(text: str) -> str:
|
||||||
|
t = text
|
||||||
|
for pat, repl in _TYPO_FIXES:
|
||||||
|
t = pat.sub(repl, t)
|
||||||
|
# khoảng trắng quanh dấu tiếng Việt kiểu "ạ ," / "không ?"
|
||||||
|
t = re.sub(r"([^\s])\s+([?!…])", r"\1\2", t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _tidy_lines(text: str) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = _MULTI_SPACE.sub(" ", line).strip()
|
||||||
|
if re.fullmatch(r"[—–\-.…\s]*", line or ""):
|
||||||
|
continue
|
||||||
|
if "—" in line or "–" in line:
|
||||||
|
parts = [p.strip() for p in _DASHY.split(line) if p.strip()]
|
||||||
|
if len(parts) > 1 and any(len(p) > 12 for p in parts):
|
||||||
|
lines.extend(parts)
|
||||||
|
continue
|
||||||
|
line = " ".join(parts)
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
t = "\n".join(lines)
|
||||||
|
t = _MULTI_NL.sub("\n\n", t).strip()
|
||||||
|
t = re.sub(r"\s+\.\s*$", ".", t)
|
||||||
|
t = re.sub(r"\.{2,}", "…", t)
|
||||||
|
t = re.sub(r"(?m)^[.\s]+$", "", t)
|
||||||
|
return _MULTI_NL.sub("\n\n", t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_for_zalo(text: str, *, max_chars: int = 1800) -> str:
|
||||||
|
"""Bỏ markdown, chuẩn hóa khoảng trắng / lỗi chính tả VN, cắt nếu quá dài."""
|
||||||
|
t = _nfc((text or "").strip())
|
||||||
|
if not t:
|
||||||
|
return t
|
||||||
|
|
||||||
|
t = _strip_markdown(t)
|
||||||
|
t = _fix_vietnamese_typos(t)
|
||||||
|
t = _tidy_lines(t)
|
||||||
|
|
||||||
|
if max_chars and len(t) > max_chars:
|
||||||
|
cut = t.rfind("\n", 0, max_chars)
|
||||||
|
if cut < max_chars // 3:
|
||||||
|
cut = t.rfind(". ", 0, max_chars)
|
||||||
|
if cut > 0:
|
||||||
|
cut += 1
|
||||||
|
if cut < max_chars // 3:
|
||||||
|
cut = max_chars
|
||||||
|
t = t[:cut].rstrip(" ,;:") + "…"
|
||||||
|
|
||||||
|
return t.strip()
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Client tới Hermes Agent — kênh Zalo riêng (không phải support KB).
|
||||||
|
|
||||||
|
Backend mặc định: Hermes API Server (OpenAI-compatible) trên gateway :8642.
|
||||||
|
Đọc env lúc gọi (không snapshot lúc import) để load_dotenv() luôn có hiệu lực.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from typing import Any, Deque, Dict, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
MISS = (
|
||||||
|
"Hermes chưa trả lời được lúc này. Bạn thử lại sau hoặc diễn đạt lại câu hỏi nhé."
|
||||||
|
)
|
||||||
|
|
||||||
|
# chat_id -> deque of {role, content} — maxlen set on first use / refresh
|
||||||
|
_HISTORIES: Dict[str, Deque[dict[str, str]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg() -> dict[str, Any]:
|
||||||
|
turns = int(os.environ.get("HERMES_HISTORY_TURNS", "12"))
|
||||||
|
return {
|
||||||
|
"base": os.environ.get("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/"),
|
||||||
|
"key": (os.environ.get("HERMES_API_KEY") or "").strip(),
|
||||||
|
"model": os.environ.get("HERMES_MODEL", "hermes-agent"),
|
||||||
|
"timeout": float(os.environ.get("HERMES_TIMEOUT", "180")),
|
||||||
|
"turns": turns,
|
||||||
|
"backend": (os.environ.get("HERMES_BACKEND") or "api").strip().lower(),
|
||||||
|
"system": (
|
||||||
|
os.environ.get("HERMES_SYSTEM_PROMPT")
|
||||||
|
or (
|
||||||
|
"Bạn đang chat với người dùng qua Zalo Bot. "
|
||||||
|
"Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. "
|
||||||
|
"Không dùng markdown nặng; ưu tiên plain text dễ đọc trên Zalo."
|
||||||
|
)
|
||||||
|
).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Compat exports used by bot_app logging
|
||||||
|
def _refresh_exports() -> None:
|
||||||
|
global HERMES_API_BASE, HERMES_BACKEND
|
||||||
|
c = _cfg()
|
||||||
|
HERMES_API_BASE = c["base"]
|
||||||
|
HERMES_BACKEND = c["backend"]
|
||||||
|
|
||||||
|
|
||||||
|
_refresh_exports()
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(key: str) -> dict[str, str]:
|
||||||
|
h = {"Content-Type": "application/json", "User-Agent": "zalo-hermes-bot/0.1"}
|
||||||
|
if key:
|
||||||
|
h["Authorization"] = f"Bearer {key}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _history_deque(chat_id: str, turns: int) -> Deque[dict[str, str]]:
|
||||||
|
key = str(chat_id)
|
||||||
|
maxlen = max(2, turns * 2)
|
||||||
|
d = _HISTORIES.get(key)
|
||||||
|
if d is None or d.maxlen != maxlen:
|
||||||
|
old = list(d) if d is not None else []
|
||||||
|
d = deque(old, maxlen=maxlen)
|
||||||
|
_HISTORIES[key] = d
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def hermes_health() -> dict[str, Any]:
|
||||||
|
c = _cfg()
|
||||||
|
_refresh_exports()
|
||||||
|
if c["backend"] == "cli":
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
["hermes", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": p.returncode == 0,
|
||||||
|
"backend": "cli",
|
||||||
|
"body": (p.stdout or p.stderr or "")[:200],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "backend": "cli", "error": str(e)}
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
f"{c['base']}/models",
|
||||||
|
c["base"].replace("/v1", "") + "/health",
|
||||||
|
"http://127.0.0.1:8642/health",
|
||||||
|
]
|
||||||
|
last: dict[str, Any] = {"ok": False, "backend": "api"}
|
||||||
|
for url in candidates:
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=5, headers=_auth_headers(c["key"]))
|
||||||
|
ctype = r.headers.get("content-type", "")
|
||||||
|
body: Any = r.json() if "json" in ctype else r.text[:200]
|
||||||
|
last = {
|
||||||
|
"ok": r.status_code < 400,
|
||||||
|
"backend": "api",
|
||||||
|
"url": url,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"body": body,
|
||||||
|
"api_key_len": len(c["key"]),
|
||||||
|
}
|
||||||
|
if last["ok"]:
|
||||||
|
return last
|
||||||
|
except Exception as e:
|
||||||
|
last = {"ok": False, "backend": "api", "url": url, "error": str(e)}
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def _history_for(chat_id: str, turns: int) -> List[dict[str, str]]:
|
||||||
|
return list(_history_deque(chat_id, turns))
|
||||||
|
|
||||||
|
|
||||||
|
def _remember(chat_id: str, role: str, content: str, turns: int) -> None:
|
||||||
|
_history_deque(chat_id, turns).append({"role": role, "content": content})
|
||||||
|
|
||||||
|
|
||||||
|
def clear_history(chat_id: str) -> None:
|
||||||
|
_HISTORIES.pop(str(chat_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def ask_hermes_api(chat_id: str, message: str) -> dict[str, Any]:
|
||||||
|
c = _cfg()
|
||||||
|
msg = (message or "").strip()
|
||||||
|
if not msg:
|
||||||
|
return {"answer": MISS, "meta": "empty"}
|
||||||
|
if not c["key"]:
|
||||||
|
print("HERMES CLIENT: HERMES_API_KEY empty — load_dotenv before import, or export env")
|
||||||
|
return {"answer": MISS, "meta": "missing api key"}
|
||||||
|
|
||||||
|
messages: list[dict[str, str]] = []
|
||||||
|
if c["system"]:
|
||||||
|
messages.append({"role": "system", "content": c["system"]})
|
||||||
|
messages.extend(_history_for(chat_id, c["turns"]))
|
||||||
|
messages.append({"role": "user", "content": msg})
|
||||||
|
|
||||||
|
url = f"{c['base']}/chat/completions"
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"model": c["model"],
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
timeout=c["timeout"],
|
||||||
|
headers=_auth_headers(c["key"]),
|
||||||
|
)
|
||||||
|
if r.status_code == 401:
|
||||||
|
print(
|
||||||
|
"HERMES CLIENT: unauthorized — "
|
||||||
|
f"api_key_len={len(c['key'])} (must match API_SERVER_KEY)"
|
||||||
|
)
|
||||||
|
return {"answer": MISS, "meta": "unauthorized"}
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
content = ""
|
||||||
|
if choices:
|
||||||
|
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return {"answer": MISS, "meta": "empty response"}
|
||||||
|
_remember(chat_id, "user", msg, c["turns"])
|
||||||
|
_remember(chat_id, "assistant", content, c["turns"])
|
||||||
|
return {"answer": content, "meta": "api"}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"HERMES CLIENT ERROR: {e}")
|
||||||
|
return {"answer": MISS, "meta": f"error: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def ask_hermes_cli(message: str) -> dict[str, Any]:
|
||||||
|
c = _cfg()
|
||||||
|
msg = (message or "").strip()
|
||||||
|
if not msg:
|
||||||
|
return {"answer": MISS, "meta": "empty"}
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
["hermes", "chat", "-q", msg],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=c["timeout"],
|
||||||
|
)
|
||||||
|
out = (p.stdout or "").strip() or (p.stderr or "").strip()
|
||||||
|
if p.returncode != 0 or not out:
|
||||||
|
print(f"HERMES CLI fail rc={p.returncode}: {out[:300]}")
|
||||||
|
return {"answer": MISS, "meta": f"cli rc={p.returncode}"}
|
||||||
|
return {"answer": out, "meta": "cli"}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"HERMES CLI ERROR: {e}")
|
||||||
|
return {"answer": MISS, "meta": f"cli error: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def ask_hermes(chat_id: str, message: str) -> dict[str, Any]:
|
||||||
|
c = _cfg()
|
||||||
|
if c["backend"] == "cli":
|
||||||
|
return ask_hermes_cli(message)
|
||||||
|
return ask_hermes_api(chat_id, message)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
python-dotenv
|
||||||
|
python-zalo-bot
|
||||||
|
requests
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Enable Hermes API Server on the local gateway (one-time helper).
|
||||||
|
# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERMES_ENV="${HERMES_HOME:-$HOME/.hermes}/.env"
|
||||||
|
KEY="${1:-change-me-local-dev}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$HERMES_ENV")"
|
||||||
|
touch "$HERMES_ENV"
|
||||||
|
|
||||||
|
set_kv() {
|
||||||
|
local k="$1" v="$2"
|
||||||
|
if grep -q "^${k}=" "$HERMES_ENV" 2>/dev/null; then
|
||||||
|
sed -i "s|^${k}=.*|${k}=${v}|" "$HERMES_ENV"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "$k" "$v" >>"$HERMES_ENV"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set_kv API_SERVER_ENABLED true
|
||||||
|
set_kv API_SERVER_KEY "$KEY"
|
||||||
|
|
||||||
|
echo "Updated $HERMES_ENV"
|
||||||
|
echo " API_SERVER_ENABLED=true"
|
||||||
|
echo " API_SERVER_KEY=${KEY}"
|
||||||
|
echo
|
||||||
|
echo "Restart gateway:"
|
||||||
|
echo " systemctl --user restart hermes-gateway"
|
||||||
|
echo "Smoke:"
|
||||||
|
echo " curl -sS http://127.0.0.1:8642/v1/models -H \"Authorization: Bearer ${KEY}\""
|
||||||
|
echo
|
||||||
|
echo "Put the same key in zalo-hermes-bot/.env as HERMES_API_KEY"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Orange Pi VN Zalo ↔ Hermes Agent bridge (python-zalo-bot)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/orangepi/workspace/zalo-hermes-bot
|
||||||
|
EnvironmentFile=-/home/orangepi/workspace/zalo-hermes-bot/.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/home/orangepi/workspace/zalo-hermes-bot/.venv/bin/python /home/orangepi/workspace/zalo-hermes-bot/bot_app.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
KillMode=mixed
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in new issue
Block a user