specmodule 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ # module_harness/command.py
2
+ """Command 节点 — 一行 shell 命令即一个 tickflow body。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import dataclasses
7
+ import subprocess
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from tickflow import Failure
13
+ from tickflow.views import DictView
14
+
15
+ from .events import (
16
+ EventBus,
17
+ CommandStarted,
18
+ CommandCompleted,
19
+ CommandFailed,
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class CommandConfig:
25
+ """shell 命令配置。"""
26
+
27
+ command: str # shell 命令字符串
28
+ timeout: float = 60.0 # 超时秒数
29
+ cwd: str | None = None # 工作目录
30
+ env: dict[str, str] | None = None # 额外环境变量
31
+ capture_output: bool = True
32
+ shell: bool = True
33
+
34
+ name: str | None = None
35
+ """注册名。submodule 的 commands 列表中必须提供。"""
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return dataclasses.asdict(self)
39
+
40
+ @classmethod
41
+ def from_dict(cls, d: dict[str, Any]) -> "CommandConfig":
42
+ return cls(**d)
43
+
44
+
45
+ class Command:
46
+ """持有 CommandConfig + EventBus,类似 Harness 类。"""
47
+
48
+ def __init__(self, config: CommandConfig, event_bus: EventBus) -> None:
49
+ self.config = config
50
+ self.bus = event_bus
51
+
52
+ def build_body(self, *, timeout: float | None = None, cwd: str | None = None):
53
+ """返回一个 sync body callable。
54
+
55
+ body 执行流程:
56
+ 1. emit CommandStarted
57
+ 2. subprocess.run(command, ...)
58
+ 3. 成功 → emit CommandCompleted → return {"stdout", "stderr", "returncode"}
59
+ 4. 异常 → emit CommandFailed → return Failure(type="llm")
60
+ """
61
+ config = self.config
62
+ bus = self.bus
63
+ final_timeout = timeout if timeout is not None else config.timeout
64
+ final_cwd = cwd if cwd is not None else config.cwd
65
+
66
+ def body(view: DictView):
67
+ node = view.node
68
+ bus.emit(CommandStarted(
69
+ timestamp=time.monotonic(), node=node, tick=0,
70
+ ))
71
+
72
+ try:
73
+ result = subprocess.run(
74
+ config.command,
75
+ shell=config.shell,
76
+ timeout=final_timeout,
77
+ cwd=final_cwd,
78
+ env=config.env,
79
+ capture_output=config.capture_output,
80
+ text=True,
81
+ # 显式 UTF-8 + replace:不依赖 locale 默认编码,非 UTF-8 控制台
82
+ # (中文 Windows GBK)的子进程输出不炸 reader 线程(D1)
83
+ encoding="utf-8",
84
+ errors="replace",
85
+ )
86
+ except subprocess.TimeoutExpired as e:
87
+ error_msg = f"命令超时 ({final_timeout}s): {e}"
88
+ bus.emit(CommandFailed(
89
+ timestamp=time.monotonic(), node=node, tick=0,
90
+ error=error_msg,
91
+ ))
92
+ return Failure(error_msg, type="llm")
93
+ except Exception as e:
94
+ error_msg = f"命令执行失败: {e}"
95
+ bus.emit(CommandFailed(
96
+ timestamp=time.monotonic(), node=node, tick=0,
97
+ error=error_msg,
98
+ ))
99
+ return Failure(error_msg, type="llm")
100
+
101
+ stdout = result.stdout or ""
102
+ stderr = result.stderr or ""
103
+
104
+ bus.emit(CommandCompleted(
105
+ timestamp=time.monotonic(), node=node, tick=0,
106
+ stdout=stdout, stderr=stderr, returncode=result.returncode,
107
+ ))
108
+
109
+ return {
110
+ "stdout": stdout,
111
+ "stderr": stderr,
112
+ "returncode": result.returncode,
113
+ }
114
+
115
+ return body
@@ -0,0 +1,95 @@
1
+ # module_harness/config.py
2
+ """HarnessConfig — harness 节点的完整配置数据模型。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import dataclasses
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from .outputfmt import OutputFormat
11
+
12
+
13
+ @dataclass
14
+ class HarnessConfig:
15
+ """harness 节点的完整配置。
16
+
17
+ 对标 tasklist 中 Task 定义的字段。
18
+ 翻译层使用 :meth:`from_task_definition` 直接构造。
19
+ """
20
+
21
+ # ── 三层 prompt ──
22
+ prompt_core: str
23
+ """Layer 1:核心提示词模板,含 {key} 占位符。"""
24
+
25
+ prompt_modes: dict[str, str] = field(default_factory=dict)
26
+ """Layer 2:动态 prompt 选项集。{"formal": "...", "casual": "..."}。"""
27
+
28
+ # ── 输出约束 ──
29
+ output_format: OutputFormat | None = None
30
+ """输出格式约束(None = 不约束)。"""
31
+
32
+ notdo: list[str] = field(default_factory=list)
33
+ """否定性约束列表,拼入 system prompt。"""
34
+
35
+ # ── LLM 参数(Task 可逐项覆盖)──
36
+ model: str | None = None
37
+ temperature: float | None = None
38
+ think: bool | dict | None = None
39
+
40
+ # ── SDK 透传参数 ──
41
+ api_params: dict[str, Any] = field(default_factory=dict)
42
+ """透传给 LLM SDK 的额外参数。按 API 官方格式写入,如
43
+ ``{"temperature": 0.3, "thinking": {"type": "enabled"}}``。
44
+ 会与 temperature / think 等独立字段合并(api_params 优先级更高)。"""
45
+
46
+ # ── 注册信息(submodule 用)──
47
+ name: str | None = None
48
+ """注册名。submodule 的 harnesses 列表中必须提供。"""
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ """序列化为 JSON 可写 dict(含 output_format)。"""
52
+ return dataclasses.asdict(self)
53
+
54
+ @classmethod
55
+ def from_dict(cls, d: dict[str, Any]) -> "HarnessConfig":
56
+ """从 to_dict() 输出还原。"""
57
+ data = dict(d)
58
+ of = data.pop("output_format", None)
59
+ if of is not None:
60
+ data["output_format"] = OutputFormat(**of)
61
+ return cls(**data)
62
+
63
+ @classmethod
64
+ def from_task_definition(cls, task: dict[str, Any]) -> "HarnessConfig":
65
+ """从 tasklist Task dict 构造 HarnessConfig。
66
+
67
+ task 中预期的键:
68
+ - prompt_core → Layer 1
69
+ - prompt_modes → Layer 2
70
+ - outputformat → 输出格式(dict,含 type/schema/instruction)
71
+ - notdo → 否定性约束列表
72
+ - model → LLM 模型覆盖
73
+ - temperature → 温度覆盖
74
+ - think → 扩展思考覆盖
75
+ - api_params → SDK 透传参数(dict)
76
+ """
77
+ output_format = None
78
+ of_data = task.get("outputformat")
79
+ if of_data is not None:
80
+ output_format = OutputFormat(
81
+ type=of_data["type"],
82
+ schema=of_data.get("schema"),
83
+ instruction=of_data.get("instruction"),
84
+ )
85
+
86
+ return cls(
87
+ prompt_core=task["prompt_core"],
88
+ prompt_modes=task.get("prompt_modes", {}),
89
+ output_format=output_format,
90
+ notdo=task.get("notdo", []),
91
+ model=task.get("model"),
92
+ temperature=task.get("temperature"),
93
+ think=task.get("think"),
94
+ api_params=task.get("api_params", {}),
95
+ )
@@ -0,0 +1,123 @@
1
+ # module_harness/consistency.py
2
+ """一致性审核 — spec + tasklist 语义一致性检查。
3
+
4
+ 独立于翻译通道:审核不经过模板,直接调用注册的审核 harness body(不走 tickflow)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from tickflow import Failure
14
+ from tickflow.views import DictView, Resolved
15
+
16
+ from .config import HarnessConfig
17
+ from .outputfmt import OutputFormat
18
+ from .registry import HarnessRegistry
19
+ from .spec import Spec, Tasklist
20
+
21
+
22
+ @dataclass
23
+ class ConsistencyReport:
24
+ """一致性审核结果。"""
25
+
26
+ consistent: bool
27
+ suggestions: str
28
+ raw: str # LLM 原始输出(审计链)
29
+
30
+
31
+ class ConsistencyError(ValueError):
32
+ """一致性审核未通过。携带完整 report,str() 输出问题描述。"""
33
+
34
+ def __init__(self, report: ConsistencyReport) -> None:
35
+ self.report = report
36
+ super().__init__(f"一致性审核未通过: {report.suggestions}")
37
+
38
+
39
+ REVIEW_HARNESS_CONFIG = HarnessConfig(
40
+ prompt_core=(
41
+ "你是一致性审核器。判断给定 tasklist 是否能实现 spec 的目标。\n"
42
+ "审核要点:\n"
43
+ "1. spec 的每个目标/需求是否被 tasklist 中的任务覆盖\n"
44
+ "2. task 中引用的字段({spec.xxx}、inputs)在 spec 中是否存在\n"
45
+ "3. flow 是否可达、是否有死路或未定义节点\n"
46
+ "spec: {spec}\n"
47
+ "tasklist: {tasklist}\n"
48
+ '输出 JSON:{"consistent": true/false, "suggestions": "..."}'
49
+ ),
50
+ output_format=OutputFormat(type="json_object"),
51
+ temperature=0.1,
52
+ )
53
+
54
+
55
+ def register_review_harness(
56
+ reg: HarnessRegistry, name: str = "spec_tasklist_review"
57
+ ) -> None:
58
+ """注册内置一致性审核 harness(默认名 spec_tasklist_review)。"""
59
+ reg.harness(name, REVIEW_HARNESS_CONFIG)
60
+
61
+
62
+ class ConsistencyReviewer:
63
+ """调用审核 harness body,返回 ConsistencyReport。"""
64
+
65
+ def __init__(
66
+ self, registry: HarnessRegistry, harness_name: str = "spec_tasklist_review"
67
+ ) -> None:
68
+ self.reg = registry
69
+ self.harness_name = harness_name
70
+
71
+ async def review(self, spec: Spec, tasklist: Tasklist) -> ConsistencyReport:
72
+ """执行一致性审核。审核失败(LLM 错误/输出不合法)抛 ValueError。"""
73
+ if self.reg.harness_config(self.harness_name) is None:
74
+ raise ValueError(
75
+ f"审核 harness '{self.harness_name}' 未注册。"
76
+ f"请先调用 register_review_harness(reg) 注册内置审核器,"
77
+ f"或自行 reg.harness('{self.harness_name}', ...)。"
78
+ )
79
+ body = self.reg.get_body(self.harness_name)
80
+
81
+ tasklist_dict = tasklist.to_dict()
82
+ state: dict[str, Any] = {}
83
+ view = DictView(
84
+ {
85
+ "spec": Resolved(value=spec.to_dict(), k=None),
86
+ "tasklist": Resolved(
87
+ value=json.dumps(tasklist_dict, ensure_ascii=False), k=None
88
+ ),
89
+ },
90
+ state=state,
91
+ node="__review__",
92
+ )
93
+ result = await body(view)
94
+
95
+ if isinstance(result, Failure):
96
+ raise ValueError(f"审核 harness 返回 Failure: {result.error}")
97
+
98
+ if isinstance(result, str):
99
+ try:
100
+ data = json.loads(result)
101
+ except json.JSONDecodeError as e:
102
+ raise ValueError(f"审核输出不是合法 JSON: {e}") from e
103
+ elif isinstance(result, dict):
104
+ data = result
105
+ else:
106
+ raise ValueError(f"审核输出类型异常: {type(result).__name__}")
107
+
108
+ if not isinstance(data, dict):
109
+ raise ValueError(f"审核输出必须是 JSON 对象: {data!r}")
110
+
111
+ consistent = data.get("consistent")
112
+ suggestions = data.get("suggestions") # 缺字段 → None → 下方 isinstance 校验抛错
113
+ if not isinstance(consistent, bool):
114
+ raise ValueError(f"审核输出缺少合法的 'consistent' 布尔字段: {data!r}")
115
+ if not isinstance(suggestions, str):
116
+ raise ValueError(f"审核输出 'suggestions' 必须是字符串: {data!r}")
117
+
118
+ raw = state.get("_llm_raw")
119
+ if raw is None:
120
+ raw = result if isinstance(result, str) else json.dumps(data, ensure_ascii=False)
121
+ return ConsistencyReport(
122
+ consistent=consistent, suggestions=suggestions, raw=raw
123
+ )
@@ -0,0 +1,74 @@
1
+ # module_harness/entry.py
2
+ """模块入口合约:ModuleEntry + 目录发现(roadmap Phase 0,CLI 使用)。
3
+
4
+ 一个 module 一个 py 文件(``modules/<name>.py``),文件内声明模块级
5
+ ``entry`` 变量。未来 ``init`` 脚手架可据此生成实例骨架
6
+ (scripts/harnesses/submodules/modules 分目录)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ from .events import EventBus
18
+ from .registry import HarnessRegistry
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ModuleEntry:
25
+ """模块入口声明:模板 + submodule + registry 构建 + 默认 spec/schema。"""
26
+
27
+ name: str
28
+ description: str
29
+ templates: dict[str, dict] # {模板名: TasklistTemplate JSON}
30
+ submodules: dict[str, type] = field(default_factory=dict) # {tasklist 名: SubModule 类}
31
+ build_registry: Callable[[Any, str, EventBus], HarnessRegistry] | None = None
32
+ default_spec: dict[str, Any] | None = None
33
+ default_template: str | None = None
34
+ spec_schema: dict[str, str] | None = None # {字段: 类型名}
35
+ review_harness: str | None = "spec_tasklist_review"
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.default_template is not None and self.default_template not in self.templates:
39
+ raise ValueError(f"default_template '{self.default_template}' 不在 templates 中")
40
+
41
+
42
+ def discover_modules(modules_dir: Path | str) -> dict[str, ModuleEntry]:
43
+ """扫描 ``modules_dir/*.py``,导入后收集模块级 ``entry`` 变量。
44
+
45
+ 缺 ``entry`` 或类型不符的文件跳过并 log 警告;同名冲突后者覆盖 + 警告;
46
+ 文件导入抛异常跳过 + log exception(不阻断整体发现)。
47
+ """
48
+ out: dict[str, ModuleEntry] = {}
49
+ d = Path(modules_dir)
50
+ if not d.is_dir():
51
+ return out
52
+ for p in sorted(d.glob("*.py")):
53
+ if p.name.startswith("_"):
54
+ continue
55
+ spec = importlib.util.spec_from_file_location(
56
+ f"specmodule_module_{p.stem}", p
57
+ )
58
+ if spec is None or spec.loader is None:
59
+ log.warning("无法加载模块入口文件(跳过): %s", p)
60
+ continue
61
+ mod = importlib.util.module_from_spec(spec)
62
+ try:
63
+ spec.loader.exec_module(mod)
64
+ except Exception:
65
+ log.exception("模块入口加载失败(跳过): %s", p)
66
+ continue
67
+ entry = getattr(mod, "entry", None)
68
+ if not isinstance(entry, ModuleEntry):
69
+ log.warning("文件 %s 缺少 entry 变量(ModuleEntry)——跳过", p)
70
+ continue
71
+ if entry.name in out:
72
+ log.warning("模块名 '%s' 重复(%s 覆盖)", entry.name, p)
73
+ out[entry.name] = entry
74
+ return out
@@ -0,0 +1,149 @@
1
+ # module_harness/events.py
2
+ """EventBus 与 harness/script 事件类型定义。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Callable
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ # ── Harness 事件基类 ──────────────────────────────────────────────
15
+
16
+ @dataclass
17
+ class HarnessEvent:
18
+ timestamp: float
19
+ node: str
20
+ tick: int
21
+
22
+
23
+ @dataclass
24
+ class PromptRendered(HarnessEvent):
25
+ rendered: str
26
+
27
+
28
+ @dataclass
29
+ class LlmCallStarted(HarnessEvent):
30
+ model: str
31
+ prompt_chars: int
32
+
33
+
34
+ @dataclass
35
+ class LlmToken(HarnessEvent):
36
+ chunk: str
37
+
38
+
39
+ @dataclass
40
+ class LlmCallCompleted(HarnessEvent):
41
+ content_chars: int
42
+ usage: dict[str, int]
43
+ finish_reason: str | None
44
+
45
+
46
+ @dataclass
47
+ class OutputValidated(HarnessEvent):
48
+ passed: bool
49
+ extracted: bool
50
+ error: str | None
51
+
52
+
53
+ @dataclass
54
+ class HarnessFailed(HarnessEvent):
55
+ reason: str
56
+ failure_type: str # "llm" | "infrastructure"
57
+
58
+
59
+ @dataclass
60
+ class ConsistencyReviewed(HarnessEvent):
61
+ """一致性审核事件(spec + 自定义 tasklist 通道)。"""
62
+ consistent: bool
63
+ suggestions: str
64
+ raw: str
65
+
66
+
67
+ # ── Script 事件基类 ──────────────────────────────────────────────
68
+
69
+ @dataclass
70
+ class ScriptEvent:
71
+ timestamp: float
72
+ node: str
73
+ tick: int
74
+
75
+
76
+ @dataclass
77
+ class ScriptStarted(ScriptEvent):
78
+ pass
79
+
80
+
81
+ @dataclass
82
+ class ScriptCompleted(ScriptEvent):
83
+ output_type: str
84
+
85
+
86
+ @dataclass
87
+ class ScriptFailed(ScriptEvent):
88
+ error: str
89
+
90
+
91
+ # ── Command 事件类型 ────────────────────────────────────────────
92
+
93
+ @dataclass
94
+ class CommandStarted(HarnessEvent):
95
+ pass
96
+
97
+
98
+ @dataclass
99
+ class CommandCompleted(HarnessEvent):
100
+ stdout: str
101
+ stderr: str
102
+ returncode: int
103
+
104
+
105
+ @dataclass
106
+ class CommandFailed(HarnessEvent):
107
+ error: str
108
+
109
+
110
+ # ── EventBus ──────────────────────────────────────────────────────
111
+
112
+ class EventBus:
113
+ """同步发布/订阅。
114
+
115
+ 回调异常 → 记录日志并吞掉(与 tickflow hooks 行为一致)。
116
+ 使用 ``EventBus.null()`` 获取静默实例(嵌入式场景)。
117
+ """
118
+
119
+ def __init__(self) -> None:
120
+ self._subscribers: dict[type, list[Callable]] = {}
121
+
122
+ def subscribe(self, event_type: type, callback: Callable) -> None:
123
+ """为某个事件类型注册回调。"""
124
+ self._subscribers.setdefault(event_type, []).append(callback)
125
+
126
+ def emit(self, event: HarnessEvent | ScriptEvent) -> None:
127
+ """发布事件到所有匹配类型的订阅者。"""
128
+ for event_type, callbacks in self._subscribers.items():
129
+ if isinstance(event, event_type):
130
+ for cb in callbacks:
131
+ try:
132
+ cb(event)
133
+ except Exception:
134
+ log.exception("EventBus callback raised; swallowed")
135
+
136
+ def on(self, event_type: type):
137
+ """装饰器方式订阅: ``@bus.on(LlmToken) def handle(e): ...``"""
138
+ def deco(fn: Callable) -> Callable:
139
+ self.subscribe(event_type, fn)
140
+ return fn
141
+ return deco
142
+
143
+ @staticmethod
144
+ def null() -> "EventBus":
145
+ """返回一个静默 EventBus,emit 无操作。"""
146
+ bus = EventBus()
147
+ # 直接替换 emit 方法为 no-op,保留 subscribe 语义但无实际操作
148
+ bus.emit = lambda event: None # type: ignore[method-assign]
149
+ return bus