Ctrl K
ComunidadGuías…

butler/gateway/wechat_scenario_sim/parsing.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml  # type: ignore[import-untyped]

from .schema import FileContainsSpec, ScenarioCase, ScenarioManifest, ScenarioTrack


def _simulation_roots(workspace: Path | str | None = None) -> list[Path]:
    ws = Path(workspace or Path.cwd()).expanduser().resolve()
    roots: list[Path] = []
    for base in (ws / ".butler" / "simulation", Path.home() / ".butler" / "simulation"):
        if base.is_dir() and base not in roots:
            roots.append(base)
    return roots


def _parse_file_contains(raw: Any) -> tuple[FileContainsSpec, ...]:
    if not raw:
        return ()
    specs: list[FileContainsSpec] = []
    if isinstance(raw, dict):
        raw = [raw]
    if not isinstance(raw, list):
        return ()
    for item in raw:
        if not isinstance(item, dict):
            continue
        path = str(item.get("path") or "").strip()
        if not path:
            continue
        any_raw = item.get("any") or item.get("substrings") or item.get("contains") or []
        if isinstance(any_raw, str):
            needles: tuple[str, ...] = (any_raw,)
        else:
            needles = tuple(str(x) for x in any_raw if str(x).strip())
        specs.append(FileContainsSpec(path=path, substrings=needles))
    return tuple(specs)


def _parse_case(raw: dict[str, Any]) -> ScenarioCase:
    def _tup(key: str) -> tuple[str, ...]:
        val = raw.get(key) or []
        if isinstance(val, str):
            return (val,)
        return tuple(str(x) for x in val)

    tier = str(raw.get("tier") or "standard").strip().lower()
    if tier not in ("fast", "standard", "slow"):
        tier = "standard"
    return ScenarioCase(
        name=str(raw.get("name") or "unnamed"),
        user_text=str(raw.get("user_text") or ""),
        expect_reply_any=_tup("expect_reply_any"),
        reject_reply_any=_tup("reject_reply_any"),
        expect_tools_any=_tup("expect_tools_any"),
        prefer_tools_any=_tup("prefer_tools_any"),
        forbid_tools=_tup("forbid_tools"),
        tier=tier,  # type: ignore[arg-type]
        max_seconds=float(raw.get("max_seconds") or 120.0),
        requires_mcp=bool(raw.get("requires_mcp")),
        skip_in_quick=bool(raw.get("skip_in_quick")),
        fresh_session=bool(raw.get("fresh_session")),
        soft=bool(raw.get("soft")),
        verify_files_exist=_tup("verify_files_exist"),
        verify_files_missing=_tup("verify_files_missing"),
        verify_file_contains=_parse_file_contains(raw.get("verify_file_contains")),
        prompt_hint=str(raw.get("prompt_hint") or "").strip(),
        require_tools=bool(raw.get("require_tools")),
        simulate_wechat_outbound=bool(raw.get("simulate_wechat_outbound")),
        expect_outbound_any=_tup("expect_outbound_any"),
        min_outbound_messages=int(raw.get("min_outbound_messages") or 0),
    )


def _parse_track(raw: dict[str, Any]) -> ScenarioTrack:
    setup_raw = raw.get("setup") or []
    if isinstance(setup_raw, str):
        setup: tuple[str, ...] = (setup_raw,)
    else:
        setup = tuple(str(x) for x in setup_raw)
    cleanup_raw = raw.get("cleanup_globs") or []
    if isinstance(cleanup_raw, str):
        cleanup_globs: tuple[str, ...] = (cleanup_raw,)
    else:
        cleanup_globs = tuple(str(x) for x in cleanup_raw)
    mode = str(raw.get("session_mode") or "shared").strip().lower()
    if mode not in ("shared", "fresh"):
        mode = "shared"
    cases = tuple(_parse_case(c) for c in (raw.get("cases") or []) if isinstance(c, dict))
    return ScenarioTrack(
        id=str(raw.get("id") or "track"),
        title=str(raw.get("title") or ""),
        session_mode=mode,  # type: ignore[arg-type]
        quick=bool(raw.get("quick", True)),
        requires_mcp=bool(raw.get("requires_mcp")),
        setup=setup,
        cleanup_globs=cleanup_globs,
        cases=cases,
        simulate_wechat_outbound=bool(raw.get("simulate_wechat_outbound")),
    )


def load_wechat_scenario_manifest(
    *,
    workspace: Path | str | None = None,
    filename: str = "wechat-owner-scenarios.yaml",
) -> ScenarioManifest | None:
    for root in _simulation_roots(workspace):
        path = root / filename
        if not path.is_file():
            continue
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        tracks = tuple(
            _parse_track(t) for t in (data.get("tracks") or []) if isinstance(t, dict)
        )
        phrases = data.get("verify_phrases") or []
        return ScenarioManifest(
            title=str(data.get("title") or "WeChat Owner Scenario Sim"),
            path=path,
            default_project=str(data.get("default_project") or "灵文1号"),
            verify_phrases=tuple(str(p) for p in phrases),
            tracks=tracks,
        )
    return None