"""蓝图生成过程日志:控制台 + 落盘,便于对账。 环境变量: - GENERATE_LOG_DIR:日志目录(默认仓库 .runtime/logs/generate) - GENERATE_LOG_LEVEL:DEBUG/INFO/WARNING(默认 INFO) """ from __future__ import annotations import logging import os import threading import uuid from contextvars import ContextVar from datetime import datetime, timezone from pathlib import Path from typing import Any _root = Path(__file__).resolve().parents[1] _LOGGER_NAME = "aijianzhan.generate" _setup_lock = threading.Lock() _configured = False _current: ContextVar["GenerateTrace | None"] = ContextVar("generate_trace", default=None) def _log_dir() -> Path: raw = (os.getenv("GENERATE_LOG_DIR") or "").strip() if raw: return Path(raw).expanduser().resolve() return (_root / ".runtime" / "logs" / "generate").resolve() def _level() -> int: name = (os.getenv("GENERATE_LOG_LEVEL") or "INFO").strip().upper() return getattr(logging, name, logging.INFO) def ensure_logging() -> logging.Logger: """幂等配置:stdout + 按日滚动的 generate.log。""" global _configured logger = logging.getLogger(_LOGGER_NAME) if _configured: return logger with _setup_lock: if _configured: return logger logger.setLevel(_level()) logger.propagate = False fmt = logging.Formatter( "%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) sh = logging.StreamHandler() sh.setLevel(_level()) sh.setFormatter(fmt) logger.addHandler(sh) try: d = _log_dir() d.mkdir(parents=True, exist_ok=True) fh = logging.FileHandler(d / "generate.log", encoding="utf-8") fh.setLevel(_level()) fh.setFormatter(fmt) logger.addHandler(fh) except OSError as e: logger.warning("无法写入生成日志目录: %s", e) _configured = True return logger class GenerateTrace: """单次 /apps/generate 请求的对账轨迹。""" def __init__(self, run_id: str | None = None) -> None: self.run_id = run_id or uuid.uuid4().hex[:10] self.started_at = datetime.now(timezone.utc).isoformat() self.lines: list[dict[str, Any]] = [] self._token = None self._logger = ensure_logging() self._run_file: Path | None = None try: d = _log_dir() d.mkdir(parents=True, exist_ok=True) self._run_file = d / f"run_{self.run_id}.log" except OSError: self._run_file = None def __enter__(self) -> "GenerateTrace": self._token = _current.set(self) self.info("generate.start", run_id=self.run_id) return self def __exit__(self, exc_type, exc, tb) -> None: if exc_type is not None: self.error(f"generate.failed: {exc}", error=str(exc)) else: self.info("generate.done") if self._token is not None: _current.reset(self._token) return False def _emit(self, level: str, message: str, **fields: Any) -> None: ts = datetime.now(timezone.utc).isoformat() entry = {"ts": ts, "level": level, "message": message} if fields: # 仅保留可 JSON 序列化的简单字段 clean: dict[str, Any] = {} for k, v in fields.items(): if v is None: continue if isinstance(v, (str, int, float, bool)): clean[k] = v elif isinstance(v, (list, tuple)) and all( isinstance(x, (str, int, float, bool)) for x in v ): clean[k] = list(v) else: clean[k] = str(v)[:500] entry.update(clean) self.lines.append(entry) extra = " ".join(f"{k}={v}" for k, v in entry.items() if k not in {"ts", "level", "message"}) line = f"[{self.run_id}] {message}" + (f" | {extra}" if extra else "") log_fn = getattr(self._logger, level if level in {"debug", "info", "warning", "error"} else "info") log_fn(line) if self._run_file is not None: try: with self._run_file.open("a", encoding="utf-8") as f: f.write(f"{ts} [{level.upper()}] {line}\n") except OSError: pass def debug(self, message: str, **fields: Any) -> None: self._emit("debug", message, **fields) def info(self, message: str, **fields: Any) -> None: self._emit("info", message, **fields) def warning(self, message: str, **fields: Any) -> None: self._emit("warning", message, **fields) def error(self, message: str, **fields: Any) -> None: self._emit("error", message, **fields) def stage(self, name: str, detail: str = "", **fields: Any) -> None: msg = f"stage.{name}" + (f": {detail}" if detail else "") self.info(msg, stage=name, **fields) def note(self, text: str) -> None: """把 warnings/notes 同步进日志。""" t = (text or "").strip() if t: self.info(t) def notes(self, items: list[str] | None) -> None: for n in items or []: self.note(str(n)) def as_payload(self) -> dict[str, Any]: return { "run_id": self.run_id, "started_at": self.started_at, "log_file": str(self._run_file) if self._run_file else "", "lines": list(self.lines), } def get_trace() -> GenerateTrace | None: return _current.get() def glog() -> GenerateTrace | None: """当前请求轨迹;无上下文时返回 None(调用方需判空)。""" return get_trace()