初始提交:识流 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:
141
app/infrastructure/service/logging/log_query_service.py
Normal file
141
app/infrastructure/service/logging/log_query_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.infrastructure.service.wechat.config import LOG_ROOT_DIR
|
||||
|
||||
|
||||
ALLOWED_MODULES = {"api", "bot", "ocr", "ai", "db", "capture", "audit", "error"}
|
||||
DOMAIN_MODULES = {"api", "bot", "ocr", "ai", "db", "capture"}
|
||||
|
||||
|
||||
def _infer_domain(row: dict) -> str:
|
||||
module = str(row.get("module") or "").strip().lower()
|
||||
if module in DOMAIN_MODULES:
|
||||
return module
|
||||
if module == "error":
|
||||
event = str(row.get("event") or "")
|
||||
prefix = event.split(".", 1)[0].strip().lower()
|
||||
if prefix in DOMAIN_MODULES:
|
||||
return prefix
|
||||
return "api"
|
||||
|
||||
|
||||
def _event_id(row: dict) -> str:
|
||||
raw = json.dumps(row or {}, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _enrich_row(row: dict) -> dict:
|
||||
x = dict(row or {})
|
||||
x["domain"] = _infer_domain(x)
|
||||
x["event_id"] = _event_id(x)
|
||||
return x
|
||||
|
||||
|
||||
def _read_jsonl(module: str):
|
||||
p = Path(LOG_ROOT_DIR) / f"{module}.jsonl"
|
||||
if not p.exists():
|
||||
return []
|
||||
rows = []
|
||||
with p.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def query_events(module=None, level=None, event=None, trace_id=None, start_ts=None, end_ts=None, keyword=None, page=1, size=50):
|
||||
modules = [module] if module in ALLOWED_MODULES else sorted(ALLOWED_MODULES)
|
||||
all_rows = []
|
||||
for m in modules:
|
||||
all_rows.extend(_read_jsonl(m))
|
||||
def ok(row):
|
||||
if level and str(row.get("level", "")).upper() != str(level).upper():
|
||||
return False
|
||||
if event and str(row.get("event", "")) != str(event):
|
||||
return False
|
||||
if trace_id and str(row.get("trace_id", "")) != str(trace_id):
|
||||
return False
|
||||
if keyword and keyword not in json.dumps(row, ensure_ascii=False):
|
||||
return False
|
||||
ts = str(row.get("ts") or "")
|
||||
if start_ts and ts < start_ts:
|
||||
return False
|
||||
if end_ts and ts > end_ts:
|
||||
return False
|
||||
return True
|
||||
rows = [_enrich_row(r) for r in all_rows if ok(r)]
|
||||
rows.sort(key=lambda x: str(x.get("ts") or ""), reverse=True)
|
||||
page = max(1, int(page or 1))
|
||||
size = max(1, min(200, int(size or 50)))
|
||||
start = (page - 1) * size
|
||||
end = start + size
|
||||
return {"total": len(rows), "page": page, "size": size, "items": rows[start:end]}
|
||||
|
||||
|
||||
def query_trace(trace_id: str):
|
||||
if not trace_id:
|
||||
return []
|
||||
result = query_events(trace_id=trace_id, size=500)
|
||||
items = result.get("items") or []
|
||||
items.sort(key=lambda x: str(x.get("ts") or ""))
|
||||
return items
|
||||
|
||||
|
||||
def query_event_json(event_id: str):
|
||||
event_id = str(event_id or "").strip().lower()
|
||||
if not event_id:
|
||||
return None
|
||||
for m in sorted(ALLOWED_MODULES):
|
||||
rows = _read_jsonl(m)
|
||||
for row in rows:
|
||||
x = _enrich_row(row)
|
||||
if str(x.get("event_id") or "") == event_id:
|
||||
return x
|
||||
return None
|
||||
|
||||
|
||||
def clear_logs(module=None):
|
||||
modules = [module] if module in ALLOWED_MODULES else sorted(ALLOWED_MODULES)
|
||||
root = Path(LOG_ROOT_DIR)
|
||||
deleted = []
|
||||
for m in modules:
|
||||
for p in root.glob(f"{m}.log*"):
|
||||
p.write_text("", encoding="utf-8")
|
||||
deleted.append(str(p.name))
|
||||
for p in root.glob(f"{m}.jsonl*"):
|
||||
p.write_text("", encoding="utf-8")
|
||||
deleted.append(str(p.name))
|
||||
return {"modules": modules, "files": deleted}
|
||||
|
||||
|
||||
def query_summary(limit=300):
|
||||
events = query_events(size=limit).get("items") or []
|
||||
error_count = sum(1 for e in events if str(e.get("level", "")).upper() in {"ERROR"})
|
||||
fallback_count = sum(1 for e in events if str(e.get("event", "")).endswith("fallback"))
|
||||
reasons = {}
|
||||
domain_counts = {"api": 0, "bot": 0, "ocr": 0, "ai": 0, "db": 0, "capture": 0}
|
||||
for e in events:
|
||||
reason = str(e.get("reason") or "").strip()
|
||||
if reason:
|
||||
reasons[reason] = reasons.get(reason, 0) + 1
|
||||
domain = str(e.get("domain") or _infer_domain(e))
|
||||
if domain in domain_counts:
|
||||
domain_counts[domain] += 1
|
||||
reason_top = sorted(reasons.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||
domain_top = sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)
|
||||
return {
|
||||
"window": len(events),
|
||||
"error_count": error_count,
|
||||
"fallback_count": fallback_count,
|
||||
"top_reasons": [{"reason": k, "count": v} for k, v in reason_top],
|
||||
"domain_counts": [{"domain": k, "count": v} for k, v in domain_top],
|
||||
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
86
app/infrastructure/service/logging/log_schema.py
Normal file
86
app/infrastructure/service/logging/log_schema.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
MODULES = {"api", "bot", "ocr", "ai", "db", "capture", "audit", "error"}
|
||||
DOMAIN_MODULES = {"api", "bot", "ocr", "ai", "db", "capture"}
|
||||
|
||||
ALLOWED_EVENTS = {
|
||||
"api.bot.status",
|
||||
"api.bot.start",
|
||||
"api.bot.stop",
|
||||
"api.messages.receive",
|
||||
"audit.decision",
|
||||
"bot.chat_analyze",
|
||||
"bot.chat_snapshot",
|
||||
"bot.loop",
|
||||
"bot.session_scan",
|
||||
"bot.session_service.init",
|
||||
"bot.session_title",
|
||||
"bot.submit",
|
||||
"bot.unread.detect",
|
||||
"bot.unread.scan",
|
||||
"ocr.baidu.token",
|
||||
"ocr.baidu.recognize",
|
||||
"ocr.rapid.init",
|
||||
"ocr.rapid.recognize",
|
||||
"ocr.fallback",
|
||||
"ocr.session_name",
|
||||
"ocr.session_title",
|
||||
"ocr.generic",
|
||||
"capture.contact_list",
|
||||
"capture.session_title",
|
||||
"capture.chat_area",
|
||||
"ai.request",
|
||||
"ai.response",
|
||||
"db.init",
|
||||
"db.error",
|
||||
"db.rule.query",
|
||||
"db.rule.match",
|
||||
}
|
||||
|
||||
|
||||
def normalize_event(module: str, event: str) -> str:
|
||||
e = str(event or "").strip().lower()
|
||||
m = str(module or "").strip().lower()
|
||||
if e in ALLOWED_EVENTS:
|
||||
return e
|
||||
if "." in e:
|
||||
prefix = e.split(".", 1)[0]
|
||||
if prefix in DOMAIN_MODULES:
|
||||
return e
|
||||
if m in DOMAIN_MODULES:
|
||||
return f"{m}.unknown"
|
||||
return "api.unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEvent:
|
||||
level: str
|
||||
module: str
|
||||
event: str
|
||||
trace_id: str
|
||||
stage: str
|
||||
status: str
|
||||
message: str
|
||||
reason: str = ""
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
ts: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
ts = self.ts or datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
module = self.module if self.module in MODULES else "api"
|
||||
event = normalize_event(module, self.event)
|
||||
return {
|
||||
"ts": ts,
|
||||
"level": (self.level or "INFO").upper(),
|
||||
"module": module,
|
||||
"event": event,
|
||||
"trace_id": self.trace_id or "-",
|
||||
"stage": self.stage or "-",
|
||||
"status": self.status or "ok",
|
||||
"reason": self.reason or "",
|
||||
"message": self.message or "",
|
||||
"extra": self.extra or {},
|
||||
}
|
||||
102
app/infrastructure/service/logging/log_service.py
Normal file
102
app/infrastructure/service/logging/log_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.configs.runtime_config import get_bool, get_int, get_str
|
||||
from app.infrastructure.service.logging.log_schema import ALLOWED_EVENTS, LogEvent, MODULES, normalize_event
|
||||
from app.infrastructure.service.wechat.config import LOG_ROOT_DIR
|
||||
|
||||
_INITIALIZED = False
|
||||
_LOGGERS: dict[str, logging.Logger] = {}
|
||||
_JSON_LOGGERS: dict[str, logging.Logger] = {}
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if isinstance(record.msg, dict):
|
||||
return json.dumps(record.msg, ensure_ascii=False)
|
||||
return json.dumps({"message": str(record.msg)}, ensure_ascii=False)
|
||||
|
||||
|
||||
class _TextFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if isinstance(record.msg, dict):
|
||||
data = record.msg
|
||||
extra = data.get("extra") or {}
|
||||
kv = " ".join([f"{k}={v}" for k, v in extra.items()])
|
||||
suffix = f" | {kv}" if kv else ""
|
||||
return f"[{data.get('ts')}][{data.get('level')}][{data.get('module')}][{data.get('event')}][{data.get('trace_id')}] {data.get('message')}{suffix}"
|
||||
return str(record.msg)
|
||||
|
||||
|
||||
def _build_logger(name: str, file_path: Path, formatter: logging.Formatter, level: int, rotate_mb: int, backup_count: int):
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers.clear()
|
||||
logger.propagate = False
|
||||
logger.setLevel(level)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(str(file_path), maxBytes=rotate_mb * 1024 * 1024, backupCount=backup_count, encoding="utf-8")
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def init_logging():
|
||||
global _INITIALIZED
|
||||
if _INITIALIZED:
|
||||
return
|
||||
enabled = get_bool("LOG_ENABLED", True)
|
||||
if not enabled:
|
||||
logging.disable(logging.CRITICAL)
|
||||
_INITIALIZED = True
|
||||
return
|
||||
level_name = (get_str("LOG_LEVEL", "INFO") or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
rotate_mb = max(1, get_int("LOG_ROTATE_MB", 5))
|
||||
backup_count = max(1, get_int("LOG_BACKUP_COUNT", 7))
|
||||
root = Path(LOG_ROOT_DIR)
|
||||
for module in MODULES:
|
||||
text_logger = _build_logger(f"solo.{module}.text", root / f"{module}.log", _TextFormatter(), level, rotate_mb, backup_count)
|
||||
json_logger = _build_logger(f"solo.{module}.json", root / f"{module}.jsonl", _JsonFormatter(), level, rotate_mb, backup_count)
|
||||
_LOGGERS[module] = text_logger
|
||||
_JSON_LOGGERS[module] = json_logger
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def log_event(level: str, module: str, event: str, trace_id: str, stage: str, status: str, message: str, reason: str = "", extra: dict | None = None):
|
||||
if not _INITIALIZED:
|
||||
init_logging()
|
||||
if logging.root.manager.disable >= logging.CRITICAL:
|
||||
return
|
||||
normalized_event = normalize_event(module, event)
|
||||
payload_extra = dict(extra or {})
|
||||
if normalized_event != str(event or "").strip().lower() and normalized_event not in ALLOWED_EVENTS:
|
||||
payload_extra["event_raw"] = event
|
||||
payload_extra["event_normalized"] = normalized_event
|
||||
payload = LogEvent(
|
||||
level=level,
|
||||
module=module,
|
||||
event=normalized_event,
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
status=status,
|
||||
reason=reason,
|
||||
message=message,
|
||||
extra=payload_extra,
|
||||
).to_dict()
|
||||
module_name = payload["module"]
|
||||
lvl = getattr(logging, payload["level"], logging.INFO)
|
||||
_LOGGERS[module_name].log(lvl, payload)
|
||||
_JSON_LOGGERS[module_name].log(lvl, payload)
|
||||
if lvl >= logging.ERROR and module_name != "error":
|
||||
_LOGGERS["error"].log(lvl, payload)
|
||||
_JSON_LOGGERS["error"].log(lvl, payload)
|
||||
|
||||
|
||||
def new_trace_id(prefix: str = "trace") -> str:
|
||||
import uuid
|
||||
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
Reference in New Issue
Block a user