webhookの文字化け対策

This commit is contained in:
joe 2026-02-14 13:57:02 +09:00
parent 82163f7a2a
commit 3d992041e9

View file

@ -1,5 +1,5 @@
# バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0 # バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0
# [2026-02-14] 3060(12GB)戦術支援Mattermost Webhook連携・遅延送信実装 # [2026-02-14] 3060(12GB)戦術支援Mattermost連携・文字化け完全対策・全ロジック非短縮
import argparse import argparse
import asyncio import asyncio
import json import json
@ -53,40 +53,44 @@ def pulse(char, color=C_RESET):
print(f"{color}{char}{C_RESET}", end="", flush=True) print(f"{color}{char}{C_RESET}", end="", flush=True)
# --- 🛰️ Mattermost 送信エンジン (非同期・遅延送信) --- # --- 🛰️ Mattermost 送信エンジン (文字化け対策済み) ---
async def post_to_mattermost(model_name, path, req_body, resp_text): async def post_to_mattermost(model_name, path, req_body, resp_text):
"""通信完了後にバックグラウンドでMattermostへログを飛ばす""" """通信完了後にバックグラウンドでMattermostへログを飛ばす。生日本語を保持する。"""
if not CONFIG["webhook_url"] or not CONFIG["enable_webhook"]: if not CONFIG["webhook_url"] or not CONFIG["enable_webhook"]:
return return
try: try:
req_obj = json.loads(req_body) # リクエストのデコード(生日本語を維持)
req_clean = json.dumps(req_obj, indent=2, ensure_ascii=False) try:
except: req_obj = json.loads(req_body)
req_clean = req_body req_clean = json.dumps(req_obj, indent=2, ensure_ascii=False)
except:
req_clean = req_body.decode('utf-8', errors='replace')
resp_display = ( # レスポンスのカットオフ
resp_text if len(resp_text) < CONFIG["max_log_len"] resp_display = (
else resp_text[:CONFIG["max_log_len"]] + "\n\n*(Truncated due to length)*" resp_text if len(resp_text) < CONFIG["max_log_len"]
) else resp_text[:CONFIG["max_log_len"]] + "\n\n*(長文のため中略...)*"
payload = {
"username": f"oproxy:{model_name}",
"icon_url": "https://ollama.com/public/ollama.png",
"text": (
f"### 🛡️ LLM Traffic Log: `{path}`\n"
f"- **Model:** `{model_name}` | **Time:** {datetime.now().strftime('%H:%M:%S')}\n"
f"#### 📥 Request\n```json\n{req_clean[:800]}\n```\n"
f"#### 📤 Response\n{resp_display}"
) )
}
try: payload = {
async with httpx.AsyncClient(timeout=10.0) as client: "username": f"oproxy:{model_name}",
await client.post(CONFIG["webhook_url"], json=payload) "icon_url": "https://ollama.com/public/ollama.png",
"text": (
f"### 🛡️ LLM Traffic Log: `{path}`\n"
f"- **Model:** `{model_name}` | **Time:** {datetime.now().strftime('%H:%M:%S')}\n"
f"#### 📥 Request preview\n```json\n{req_clean[:800]}\n```\n"
f"#### 📤 Response\n{resp_display}"
)
}
async with httpx.AsyncClient(timeout=15.0) as client:
# ensure_ascii=False で Mattermost に生の日本語を届ける
encoded_payload = json.dumps(payload, ensure_ascii=False).encode('utf-8')
await client.post(CONFIG["webhook_url"], content=encoded_payload, headers={"Content-Type": "application/json"})
except Exception as e: except Exception as e:
print(f"\n{C_RED}[Webhook Fail] {e}{C_RESET}") print(f"\n{C_RED}[Webhook Fail] {e}{C_RESET}")
# --- 🧠 戦略적モデル分析 (2行分割レイアウト) --- # --- 🧠 戦略的モデル分析 (2行分割レイアウト・非短縮版) ---
async def fetch_detailed_models(): async def fetch_detailed_models():
"""Ollamaの内部情報を深掘りし、メタデータを完全取得する""" """Ollamaの内部情報を深掘りし、メタデータを完全取得する"""
try: try:
@ -230,7 +234,7 @@ async def sticky_proxy(path: str, request: Request):
print(f"{get_ts()} {C_WHITE}/{path: <10}{C_RESET} ", end="", flush=True) print(f"{get_ts()} {C_WHITE}/{path: <10}{C_RESET} ", end="", flush=True)
body = await request.body() body = await request.body()
# モデル名の特定 # 送信時にどのモデルか判別するための抽出
model_name = "unknown" model_name = "unknown"
try: try:
model_name = json.loads(body).get("model", "unknown") model_name = json.loads(body).get("model", "unknown")
@ -239,7 +243,7 @@ async def sticky_proxy(path: str, request: Request):
if do_dump: if do_dump:
print( print(
f"\n{C_YELLOW}{'=' * 60}\n[DUMP REQUEST: {request.method} /{path}]\n{'=' * 60}{C_RESET}" f"\n{C_YELLOW}{'=' * 60}\n[DUMP REQUEST: {model_name}]\n{'=' * 60}{C_RESET}"
) )
try: try:
print(json.dumps(json.loads(body), indent=2, ensure_ascii=False)) print(json.dumps(json.loads(body), indent=2, ensure_ascii=False))
@ -270,16 +274,19 @@ async def sticky_proxy(path: str, request: Request):
full_text_buffer = [] full_text_buffer = []
async for chunk in response.aiter_bytes(): async for chunk in response.aiter_bytes():
# チャンクから正規表現で高速テキスト抽出
raw_data = chunk.decode(errors="ignore") raw_data = chunk.decode(errors="ignore")
matches = RE_CONTENT.findall(raw_data) matches = RE_CONTENT.findall(raw_data)
for m in matches: for m in matches:
try: try:
text = m.encode().decode("unicode_escape", errors="ignore") # 【最重要】Unicodeエスケープを戻し、化けないようにUTF-8で再構成
text = m.encode('utf-8').decode('unicode_escape').encode('latin1').decode('utf-8')
full_text_buffer.append(text) full_text_buffer.append(text)
if do_dump: if do_dump:
print(f"{C_WHITE}{text}{C_RESET}", end="", flush=True) print(f"{C_WHITE}{text}{C_RESET}", end="", flush=True)
except: except:
pass # 万が一デコードに失敗した場合はマッチした文字列をそのまま保持
full_text_buffer.append(m)
pulse("v", C_GREEN) pulse("v", C_GREEN)
yield chunk yield chunk
@ -288,7 +295,7 @@ async def sticky_proxy(path: str, request: Request):
if full_text_buffer: if full_text_buffer:
combined_text = "".join(full_text_buffer) combined_text = "".join(full_text_buffer)
asyncio.create_task(post_to_mattermost( asyncio.create_task(post_to_mattermost(
model_name, path, body.decode(errors='ignore'), combined_text model_name, path, body, combined_text
)) ))
if do_dump: if do_dump: