Роутер работает нормально в process v2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Core helpers for pipeline_setup_v4."""
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from tests.pipeline_setup_v4.core.models import V4CaseResult
|
||||
|
||||
|
||||
class ArtifactLayout:
|
||||
def __init__(self, *, run_name: str, started_at: datetime) -> None:
|
||||
self._run_name = run_name
|
||||
self._stamp = started_at.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def run_dir_for(self, source_file: Path) -> Path:
|
||||
path = source_file.parent / "test_runs" / self._run_name / self._stamp
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class ArtifactWriter:
|
||||
def __init__(self, layout: ArtifactLayout) -> None:
|
||||
self._layout = layout
|
||||
|
||||
def write_case(self, result: V4CaseResult) -> Path:
|
||||
run_dir = self._layout.run_dir_for(result.case.source_file)
|
||||
stem = f"{result.case.source_file.stem}_{result.case.case_id}"
|
||||
json_path = run_dir / f"{stem}.json"
|
||||
md_path = run_dir / f"{stem}.md"
|
||||
json_path.write_text(json.dumps(self._json_payload(result), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
md_path.write_text(self._markdown_payload(result), encoding="utf-8")
|
||||
return md_path
|
||||
|
||||
def write_summaries(self, results: list[V4CaseResult]) -> list[Path]:
|
||||
grouped: dict[Path, list[V4CaseResult]] = defaultdict(list)
|
||||
for result in results:
|
||||
grouped[self._layout.run_dir_for(result.case.source_file)].append(result)
|
||||
paths: list[Path] = []
|
||||
for run_dir, items in sorted(grouped.items(), key=lambda item: item[0].as_posix()):
|
||||
path = run_dir / "summary.md"
|
||||
path.write_text(SummaryComposer().compose(items), encoding="utf-8")
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
def _json_payload(self, result: V4CaseResult) -> dict:
|
||||
return {
|
||||
"meta": {
|
||||
"case_id": result.case.case_id,
|
||||
"component": result.case.component,
|
||||
"source_file": result.case.source_file.as_posix(),
|
||||
"passed": result.passed,
|
||||
"mismatches": result.mismatches,
|
||||
},
|
||||
"actual": result.actual,
|
||||
"details": result.details,
|
||||
}
|
||||
|
||||
def _markdown_payload(self, result: V4CaseResult) -> str:
|
||||
lines = [
|
||||
f"# {result.case.case_id}",
|
||||
"",
|
||||
f"- component: {result.case.component}",
|
||||
f"- source_file: {result.case.source_file.as_posix()}",
|
||||
f"- passed: {result.passed}",
|
||||
"",
|
||||
"## Query",
|
||||
result.case.query,
|
||||
"",
|
||||
"## Actual",
|
||||
"```json",
|
||||
json.dumps(result.actual, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
"## Details",
|
||||
"```json",
|
||||
json.dumps(result.details, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
"## Mismatches",
|
||||
*([f"- {item}" for item in result.mismatches] or ["- none"]),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class SummaryComposer:
|
||||
def compose(self, results: list[V4CaseResult]) -> str:
|
||||
passed = sum(1 for item in results if item.passed)
|
||||
lines = [
|
||||
"# pipeline_setup_v4 summary",
|
||||
"",
|
||||
f"Passed: {passed}/{len(results)}",
|
||||
"",
|
||||
"| Case | Component | Query | Intent | Sub-intent | Pass |",
|
||||
"|------|-----------|-------|--------|------------|------|",
|
||||
]
|
||||
for item in results:
|
||||
lines.append(
|
||||
f"| {item.case.case_id} | {item.case.component} | {self._cell(item.case.query)} | "
|
||||
f"{item.actual.get('intent') or '—'} | {item.actual.get('sub_intent') or '—'} | "
|
||||
f"{'✓' if item.passed else '✗'} |"
|
||||
)
|
||||
failures = [item for item in results if not item.passed]
|
||||
if failures:
|
||||
lines.extend(["", "## Failures"])
|
||||
for item in failures:
|
||||
lines.append(f"- **{item.case.case_id}**: {'; '.join(item.mismatches)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cell(self, text: str, limit: int = 140) -> str:
|
||||
compact = " ".join(text.split()).replace("|", "\\|")
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return compact[: limit - 1].rstrip() + "…"
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from tests.pipeline_setup_v4.core.models import CaseExpectations, RouterExpectation, V4Case
|
||||
|
||||
|
||||
class CaseDirectoryLoader:
|
||||
def load(self, cases_dir: Path) -> list[V4Case]:
|
||||
if cases_dir.is_file():
|
||||
return self._load_file(cases_dir)
|
||||
files = sorted(path for path in cases_dir.rglob("*.yaml") if path.is_file())
|
||||
if not files:
|
||||
raise ValueError(f"No YAML case files found in: {cases_dir}")
|
||||
cases: list[V4Case] = []
|
||||
for path in files:
|
||||
cases.extend(self._load_file(path))
|
||||
return cases
|
||||
|
||||
def _load_file(self, path: Path) -> list[V4Case]:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Invalid case file {path}: expected mapping")
|
||||
defaults = dict(payload.get("defaults") or {})
|
||||
items = payload.get("cases") or []
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"Invalid case file {path}: `cases` must be a list")
|
||||
return [self._to_case(path, raw, defaults) for raw in items]
|
||||
|
||||
def _to_case(self, path: Path, raw: object, defaults: dict) -> V4Case:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Invalid case in {path}: expected object")
|
||||
case_id = str(raw.get("id") or "").strip()
|
||||
component = str(raw.get("component") or defaults.get("component") or "").strip()
|
||||
query = str(raw.get("query") or "").strip()
|
||||
if not case_id or not component or not query:
|
||||
raise ValueError(f"Invalid case in {path}: `id`, `component`, `query` are required")
|
||||
expected = dict(raw.get("expected") or {})
|
||||
return V4Case(
|
||||
case_id=case_id,
|
||||
component=component, # type: ignore[arg-type]
|
||||
query=query,
|
||||
source_file=path,
|
||||
expectations=self._to_expectations(expected),
|
||||
notes=str(raw.get("notes") or ""),
|
||||
tags=tuple(str(item).strip() for item in raw.get("tags") or [] if str(item).strip()),
|
||||
)
|
||||
|
||||
def _to_expectations(self, raw: dict) -> CaseExpectations:
|
||||
router = dict(raw.get("router") or {})
|
||||
return CaseExpectations(
|
||||
router=RouterExpectation(
|
||||
domain=str(router.get("domain") or "").strip() or None,
|
||||
intent=str(router.get("intent") or "").strip() or None,
|
||||
sub_intent=str(router.get("sub_intent") or "").strip() or None,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ComponentKind = Literal["process_v2_intent_router"]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RouterExpectation:
|
||||
domain: str | None = None
|
||||
intent: str | None = None
|
||||
sub_intent: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CaseExpectations:
|
||||
router: RouterExpectation = RouterExpectation()
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class V4Case:
|
||||
case_id: str
|
||||
component: ComponentKind
|
||||
query: str
|
||||
source_file: Path
|
||||
expectations: CaseExpectations = CaseExpectations()
|
||||
notes: str = ""
|
||||
tags: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ExecutionPayload:
|
||||
actual: dict
|
||||
details: dict
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class V4CaseResult:
|
||||
case: V4Case
|
||||
actual: dict
|
||||
details: dict
|
||||
passed: bool
|
||||
mismatches: list[str] = field(default_factory=list)
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from tests.pipeline_setup_v4.core.artifacts import ArtifactLayout, ArtifactWriter
|
||||
from tests.pipeline_setup_v4.core.case_loader import CaseDirectoryLoader
|
||||
from tests.pipeline_setup_v4.core.models import V4CaseResult
|
||||
from tests.pipeline_setup_v4.core.validators import CaseValidator
|
||||
from tests.pipeline_setup_v4.executors.registry import ExecutorRegistry
|
||||
|
||||
|
||||
class V4Runner:
|
||||
def __init__(self, cases_dir: Path, *, run_name: str) -> None:
|
||||
self._cases_dir = cases_dir
|
||||
self._validator = CaseValidator()
|
||||
self._executors = ExecutorRegistry()
|
||||
self._writer = ArtifactWriter(ArtifactLayout(run_name=run_name, started_at=datetime.now()))
|
||||
|
||||
def run(self) -> tuple[list[V4CaseResult], list[Path]]:
|
||||
results: list[V4CaseResult] = []
|
||||
for case in CaseDirectoryLoader().load(self._cases_dir):
|
||||
payload = self._executors.execute(case.component, case)
|
||||
mismatches = self._validator.validate(case, payload.actual)
|
||||
result = V4CaseResult(
|
||||
case=case,
|
||||
actual=payload.actual,
|
||||
details=payload.details,
|
||||
passed=not mismatches,
|
||||
mismatches=mismatches,
|
||||
)
|
||||
self._writer.write_case(result)
|
||||
results.append(result)
|
||||
return results, self._writer.write_summaries(results)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.pipeline_setup_v4.core.models import V4Case
|
||||
|
||||
|
||||
class CaseValidator:
|
||||
def validate(self, case: V4Case, actual: dict) -> list[str]:
|
||||
mismatches: list[str] = []
|
||||
expected = case.expectations.router
|
||||
self._check(expected.domain, actual.get("domain"), "domain", mismatches)
|
||||
self._check(expected.intent, actual.get("intent"), "intent", mismatches)
|
||||
self._check(expected.sub_intent, actual.get("sub_intent"), "sub_intent", mismatches)
|
||||
return mismatches
|
||||
|
||||
def _check(self, expected: str | None, actual: object, label: str, mismatches: list[str]) -> None:
|
||||
if expected is not None and expected != actual:
|
||||
mismatches.append(f"{label}: expected {expected}, got {actual}")
|
||||
Reference in New Issue
Block a user