From 7b61af7be3cbfa183b9c52d51877b1f04af135cb Mon Sep 17 00:00:00 2001 From: Val-sss <154882199@qq.com> Date: Wed, 6 May 2026 20:13:26 +0800 Subject: [PATCH] feat: audit log + model cmd + indent alignment fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit log (audit/logger.py): - Persists task execution trace as human-readable JSON - Storage: ~/.kaiwu/logs/, max 100, auto-cleanup - Records: task, gate, experts, files, tests, retries (no code content) - orchestrator._emit() now instance method, auto-logs to audit - CLI: kwcode log / log show / log clear Model commands (cli/commands/model_cmd.py): - kwcode model: show current config + tier - kwcode model set : switch model (writes config.yaml) - kwcode model probe: detect family/params/quant/reasoning via Ollama API Indent alignment fix (Generator._align_indentation): - Fixes systematic bug: LLM returns class methods at 0-indent, apply_patch replaces 4-indent original → method escapes class - Aligns modified base indent to match original before apply_patch Tests: 10 new (audit 4 + indent 5 + hashline prompt 1), 50 total green Co-Authored-By: Claude Opus 4.6 (1M context) --- STATUS.md | 18 ++++ kaiwu/audit/__init__.py | 0 kaiwu/audit/logger.py | 180 ++++++++++++++++++++++++++++++++ kaiwu/cli/commands/log_cmd.py | 108 +++++++++++++++++++ kaiwu/cli/commands/model_cmd.py | 118 +++++++++++++++++++++ kaiwu/cli/main.py | 4 + kaiwu/core/orchestrator.py | 13 ++- kaiwu/experts/generator.py | 43 ++++++++ tests/test_audit_model.py | 165 +++++++++++++++++++++++++++++ 9 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 kaiwu/audit/__init__.py create mode 100644 kaiwu/audit/logger.py create mode 100644 kaiwu/cli/commands/log_cmd.py create mode 100644 kaiwu/cli/commands/model_cmd.py create mode 100644 tests/test_audit_model.py diff --git a/STATUS.md b/STATUS.md index 250bdbf..86328d5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -53,6 +53,24 @@ - orchestrator._record_success/_record_failure_result: 调_record_flywheel() - _record_flywheel(): 策略统计 + 用户模式 + 遥测,三路全非阻塞 +**审计日志** (`audit/logger.py`) +- AuditLogger: start() → log(stage, detail) → write(ctx, elapsed, success, model) +- 存储:~/.kaiwu/logs/YYYY-MM-DD_HHMMSS_.json +- 不记录代码内容,只记录:任务描述/Gate分类/专家执行时间/文件名/测试结果/重试次数 +- 最多保留100条,超出自动清理 +- orchestrator._emit()从@staticmethod改为实例方法,每个事件自动记录到audit +- CLI: `kwcode log` / `kwcode log show ` / `kwcode log clear` + +**kwcode model命令** (`cli/commands/model_cmd.py`) +- `kwcode model` — 显示当前模型配置+能力tier +- `kwcode model set <名称>` — 切换模型(写入config.yaml) +- `kwcode model probe` — 探测模型详情(Ollama API: family/参数量/量化/reasoning) + +**缩进对齐修复** (`Generator._align_indentation`) +- 修复系统性bug:LLM返回class方法时丢失缩进(0空格 vs 原始4空格) +- apply_patch替换后方法"跑出"class导致IndentationError +- 修法:_generate_modified()返回后立刻调_align_indentation()补齐缩进差 + **P0: Hashline锚点编辑** (`tools/hashline.py`) - add_anchors(): 每行加6字符MD5哈希锚点 `行号|哈希| 内容` - parse_anchor_edits(): 解析 EDIT/DELETE/INSERT_AFTER 指令 diff --git a/kaiwu/audit/__init__.py b/kaiwu/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kaiwu/audit/logger.py b/kaiwu/audit/logger.py new file mode 100644 index 0000000..211b571 --- /dev/null +++ b/kaiwu/audit/logger.py @@ -0,0 +1,180 @@ +""" +审计日志:持久化任务执行轨迹为人类可读格式。 + +存储位置:.kaiwu/logs/YYYY-MM-DD_HHMMSS_.json +不记录代码内容,只记录元数据和行为轨迹。 +最多保留100条,超出自动清理最旧的。 +""" + +import json +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +LOGS_DIR = Path.home() / ".kaiwu" / "logs" +MAX_LOGS = 100 + + +class AuditLogger: + """任务执行审计日志。""" + + def __init__(self): + self._events: list[dict] = [] + self._start_time: float = 0 + + def start(self): + """任务开始时调用。""" + self._events = [] + self._start_time = time.time() + + def log(self, stage: str, detail: str): + """记录一个执行事件。""" + elapsed = time.time() - self._start_time if self._start_time else 0 + self._events.append({ + "time": datetime.now().strftime("%H:%M:%S"), + "elapsed_s": round(elapsed, 1), + "stage": stage, + "detail": detail, + }) + + def write(self, ctx, elapsed: float, success: bool, model: str = "unknown"): + """ + 任务完成时写入日志文件。非阻塞,失败静默。 + + Args: + ctx: TaskContext + elapsed: 总耗时秒数 + success: 是否成功 + model: 模型名称 + """ + try: + LOGS_DIR.mkdir(parents=True, exist_ok=True) + + gate = ctx.gate_result or {} + expert_type = gate.get("expert_type", "unknown") + difficulty = gate.get("difficulty", "?") + ts = datetime.now().strftime("%Y-%m-%d_%H%M%S") + filename = f"{ts}_{expert_type}.json" + + # 提取修改的文件列表(不含内容) + files_modified = [] + patches = [] + if ctx.generator_output: + patches = ctx.generator_output.get("patches", []) + files_modified = [p.get("file", "") for p in patches] + + # 计算改动行数 + lines_added = 0 + lines_removed = 0 + for p in patches: + orig_lines = len(p.get("original", "").split("\n")) if p.get("original") else 0 + mod_lines = len(p.get("modified", "").split("\n")) if p.get("modified") else 0 + lines_added += max(0, mod_lines - orig_lines) + lines_removed += max(0, orig_lines - mod_lines) + + # 测试结果 + tests_passed = 0 + tests_total = 0 + if ctx.verifier_output: + tests_passed = ctx.verifier_output.get("tests_passed", 0) + tests_total = ctx.verifier_output.get("tests_total", 0) + + record = { + "task": ctx.user_input[:200], # 保留任务描述(用户自己的输入) + "timestamp": datetime.now().isoformat(), + "model": model, + "expert_type": expert_type, + "difficulty": difficulty, + "elapsed_s": round(elapsed, 1), + "success": success, + "retry_count": ctx.retry_count, + "files_modified": files_modified, + "lines_added": lines_added, + "lines_removed": lines_removed, + "tests_passed": tests_passed, + "tests_total": tests_total, + "search_triggered": ctx.search_triggered, + "events": self._events, + } + + log_path = LOGS_DIR / filename + log_path.write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + # 清理超过MAX_LOGS的旧日志 + self._cleanup() + + except Exception as e: + logger.debug("Audit log write failed (non-blocking): %s", e) + + def _cleanup(self): + """保留最近MAX_LOGS条日志,删除最旧的。""" + try: + logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name) + if len(logs) > MAX_LOGS: + for old in logs[:len(logs) - MAX_LOGS]: + old.unlink() + except Exception: + pass + + +def list_logs(limit: int = 20) -> list[dict]: + """列出最近的日志摘要。""" + if not LOGS_DIR.exists(): + return [] + + logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name, reverse=True) + result = [] + for i, path in enumerate(logs[:limit]): + try: + data = json.loads(path.read_text(encoding="utf-8")) + result.append({ + "id": len(logs) - i, + "file": path.name, + "task": data.get("task", "")[:60], + "success": data.get("success", False), + "elapsed_s": data.get("elapsed_s", 0), + "timestamp": data.get("timestamp", ""), + "model": data.get("model", ""), + }) + except Exception: + continue + + return result + + +def show_log(log_id: int) -> Optional[dict]: + """获取指定ID的日志详情。""" + if not LOGS_DIR.exists(): + return None + + logs = sorted(LOGS_DIR.glob("*.json"), key=lambda p: p.name) + idx = log_id - 1 + if idx < 0 or idx >= len(logs): + return None + + try: + return json.loads(logs[idx].read_text(encoding="utf-8")) + except Exception: + return None + + +def clear_logs() -> int: + """清除所有日志,返回删除数量。""" + if not LOGS_DIR.exists(): + return 0 + count = 0 + for path in LOGS_DIR.glob("*.json"): + try: + path.unlink() + count += 1 + except Exception: + pass + return count diff --git a/kaiwu/cli/commands/log_cmd.py b/kaiwu/cli/commands/log_cmd.py new file mode 100644 index 0000000..1df730c --- /dev/null +++ b/kaiwu/cli/commands/log_cmd.py @@ -0,0 +1,108 @@ +""" +kwcode log — 任务历史审计日志命令。 +""" + +import typer +from rich.prompt import Confirm + +from kaiwu.cli.formatters import console + +log_app = typer.Typer(name="log", help="任务历史日志") + + +@log_app.callback(invoke_without_command=True) +def log_list( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", "-n", help="显示条数"), +): + """查看最近的任务历史。""" + if ctx.invoked_subcommand is not None: + return + + from kaiwu.audit.logger import list_logs + + logs = list_logs(limit=limit) + if not logs: + console.print(" [dim]暂无任务记录[/dim]") + return + + console.print() + console.print(" [bold]任务历史[/bold]") + console.print(" " + "─" * 55) + + for entry in logs: + icon = "[green]✅[/green]" if entry["success"] else "[red]❌[/red]" + elapsed = f"{entry['elapsed_s']:.0f}s" + task = entry["task"][:40] + ts = entry["timestamp"][:16].replace("T", " ") if entry["timestamp"] else "" + + console.print( + f" #{entry['id']:<3} {icon} {elapsed:>4} {task:<40} [dim]{ts}[/dim]" + ) + + console.print() + console.print(" [dim]查看详情:kwcode log show <编号>[/dim]") + console.print() + + +@log_app.command("show") +def log_show( + log_id: int = typer.Argument(..., help="日志编号"), +): + """查看某次任务的详细执行过程。""" + from kaiwu.audit.logger import show_log + + data = show_log(log_id) + if not data: + console.print(f" [red]未找到日志 #{log_id}[/red]") + return + + icon = "✅ 成功" if data.get("success") else "❌ 失败" + + console.print() + console.print(f" [bold]任务 #{log_id} 详情[/bold]") + console.print(" " + "─" * 45) + console.print(f" 任务:{data.get('task', '')[:80]}") + console.print(f" 时间:{data.get('timestamp', '')[:19]}") + console.print(f" 模型:{data.get('model', '')}") + console.print(f" 耗时:{data.get('elapsed_s', 0):.1f}秒") + console.print(f" 结果:{icon}") + console.print() + + # 执行过程 + events = data.get("events", []) + if events: + console.print(" [bold cyan]执行过程[/bold cyan]") + for ev in events: + stage = ev.get("stage", "") + detail = ev.get("detail", "")[:80] + t = ev.get("time", "") + console.print(f" {t} [{stage:<15}] {detail}") + console.print() + + # 修改文件 + files = data.get("files_modified", []) + if files: + console.print(f" 修改文件:{', '.join(files)}") + console.print(f" 改动规模:+{data.get('lines_added', 0)}行 -{data.get('lines_removed', 0)}行") + + tp = data.get("tests_passed", 0) + tt = data.get("tests_total", 0) + if tt > 0: + console.print(f" 测试结果:{tp}/{tt}") + + console.print(f" 重试次数:{data.get('retry_count', 0)}") + console.print() + + +@log_app.command("clear") +def log_clear(): + """清除所有任务日志。""" + confirm = Confirm.ask(" 确认清除所有任务日志?", default=False) + if not confirm: + console.print(" [dim]已取消[/dim]") + return + + from kaiwu.audit.logger import clear_logs + count = clear_logs() + console.print(f" [green]已清除 {count} 条日志[/green]") diff --git a/kaiwu/cli/commands/model_cmd.py b/kaiwu/cli/commands/model_cmd.py new file mode 100644 index 0000000..5491c78 --- /dev/null +++ b/kaiwu/cli/commands/model_cmd.py @@ -0,0 +1,118 @@ +""" +kwcode model — 模型查看/切换/探测命令。 +""" + +import typer + +from kaiwu.cli.formatters import console + +model_app = typer.Typer(name="model", help="模型管理") + + +@model_app.callback(invoke_without_command=True) +def model_show(ctx: typer.Context): + """查看当前模型配置。""" + if ctx.invoked_subcommand is not None: + return + + from kaiwu.cli.onboarding import load_config + + config = load_config() + cfg = config.get("default", {}) + model = cfg.get("model", "未配置") + base_url = cfg.get("base_url", "http://localhost:11434") + has_key = bool(cfg.get("api_key")) + + console.print() + console.print(f" [bold]当前模型配置[/bold]") + console.print(" " + "─" * 40) + console.print(f" 模型:{model}") + console.print(f" API :{base_url}") + console.print(f" Key :{'已配置' if has_key else '(无)'}") + + # Try detect tier + try: + from kaiwu.core.model_capability import detect_model_tier + tier = detect_model_tier(model, base_url) + console.print(f" 能力:{tier.value}") + except Exception: + pass + + console.print() + console.print(" [dim]切换模型:kwcode model set <模型名>[/dim]") + console.print(" [dim]探测能力:kwcode model probe[/dim]") + console.print() + + +@model_app.command("set") +def model_set( + name: str = typer.Argument(..., help="模型名称"), +): + """切换模型(写入 config.yaml)。""" + from kaiwu.cli.onboarding import load_config, _save_config + + config = load_config() + config.setdefault("default", {}) + config["default"]["model"] = name + _save_config(config) + console.print(f" [green]✓ 模型已切换为 {name}[/green]") + console.print(" [dim]重新启动 kwcode 生效[/dim]") + + +@model_app.command("probe") +def model_probe(): + """探测当前模型能力(参数量/上下文/推理能力)。""" + from kaiwu.cli.onboarding import load_config + + config = load_config() + cfg = config.get("default", {}) + model = cfg.get("model", "未配置") + base_url = cfg.get("base_url", "http://localhost:11434") + + console.print(f" 探测模型 {model}...") + + # Try Ollama API + try: + import httpx + resp = httpx.post( + f"{base_url}/api/show", + json={"name": model}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + params = data.get("details", {}).get("parameter_size", "未知") + family = data.get("details", {}).get("family", "未知") + quant = data.get("details", {}).get("quantization_level", "未知") + console.print() + console.print(f" [bold]模型信息[/bold]") + console.print(" " + "─" * 40) + console.print(f" 名称:{model}") + console.print(f" 家族:{family}") + console.print(f" 参数:{params}") + console.print(f" 量化:{quant}") + + # Detect reasoning + try: + from kaiwu.llm.llama_backend import LLMBackend + is_reasoning = LLMBackend._check_reasoning_model(model) + console.print(f" 推理:{'✅ reasoning模型' if is_reasoning else '标准模型'}") + except Exception: + pass + + # Detect tier + try: + from kaiwu.core.model_capability import detect_model_tier + tier = detect_model_tier(model, base_url) + console.print(f" 能力:{tier.value}") + except Exception: + pass + + console.print() + return + except Exception: + pass + + console.print(f" [yellow]无法通过 Ollama API 探测 {model}[/yellow]") + console.print(f" [dim]请确认 {base_url} 可访问且模型已下载[/dim]") + console.print() diff --git a/kaiwu/cli/main.py b/kaiwu/cli/main.py index b4f0459..c0fac80 100644 --- a/kaiwu/cli/main.py +++ b/kaiwu/cli/main.py @@ -55,6 +55,8 @@ from kaiwu.cli.commands.task import ( build_pipeline, run_task, ) +from kaiwu.cli.commands.log_cmd import log_app +from kaiwu.cli.commands.model_cmd import model_app from kaiwu.cli.repl import repl app = typer.Typer( @@ -68,6 +70,8 @@ app.add_typer(expert_app) app.add_typer(checkpoint_app) app.add_typer(telemetry_app) app.add_typer(skill_app) +app.add_typer(log_app) +app.add_typer(model_app) app.command("init")(cmd_init) app.command("memory")(cmd_memory) app.command("status")(cmd_status) diff --git a/kaiwu/core/orchestrator.py b/kaiwu/core/orchestrator.py index 0c2c7a5..d87a96d 100644 --- a/kaiwu/core/orchestrator.py +++ b/kaiwu/core/orchestrator.py @@ -40,6 +40,7 @@ from kaiwu.notification.flywheel_notifier import FlywheelNotifier from kaiwu.flywheel.strategy_stats import StrategyStats from kaiwu.flywheel.user_pattern_memory import UserPatternMemory from kaiwu.telemetry.client import TelemetryClient +from kaiwu.audit.logger import AuditLogger logger = logging.getLogger(__name__) @@ -133,6 +134,7 @@ class PipelineOrchestrator: self._strategy_stats = StrategyStats() self._user_patterns = UserPatternMemory() self._telemetry = TelemetryClient() + self._audit = AuditLogger() self.bus = bus or EventBus() self._wink = WinkMonitor() self._cognitive_gate = CognitiveGate() @@ -156,6 +158,7 @@ class PipelineOrchestrator: Returns {"success": bool, "context": TaskContext, "error": str|None, "elapsed": float}. """ start_time = time.time() + self._audit.start() # 任务级超时看门狗 TASK_TIMEOUT_S = 300 # 单任务最长5分钟 @@ -573,6 +576,8 @@ class PipelineOrchestrator: self._persist_reflection(project_root, ctx, gate_result, success=True) # 飞轮:策略统计 + 用户模式 + 遥测 self._record_flywheel(ctx, gate_result, True) + # 审计日志 + self._audit.write(ctx, elapsed, True, getattr(self, '_model_name', 'unknown')) return { "success": True, "context": ctx, @@ -611,6 +616,8 @@ class PipelineOrchestrator: self._persist_reflection(project_root, ctx, gate_result, success=False) # 飞轮:策略统计 + 用户模式 + 遥测 self._record_flywheel(ctx, gate_result, False) + # 审计日志 + self._audit.write(ctx, elapsed, False, getattr(self, '_model_name', 'unknown')) return { "success": False, "context": ctx, @@ -823,12 +830,12 @@ class PipelineOrchestrator: logger.debug("Reviewer failed (non-blocking): %s", e) return {"aligned": True, "confidence": 0.0, "gap": ""} - @staticmethod - def _emit(callback, stage: str, detail: str): - """Emit status update if callback provided.""" + def _emit(self, callback, stage: str, detail: str): + """Emit status update if callback provided. Also logs to audit.""" if callback: callback(stage, detail) logger.info("[%s] %s", stage, detail) + self._audit.log(stage, detail) def _get_max_retries(self, gate_result: dict) -> int: """Dynamic retry budget based on task difficulty.""" diff --git a/kaiwu/experts/generator.py b/kaiwu/experts/generator.py index 5bbf936..90ececf 100644 --- a/kaiwu/experts/generator.py +++ b/kaiwu/experts/generator.py @@ -277,6 +277,9 @@ class GeneratorExpert: if not modified: continue + # 对齐缩进:LLM返回的modified可能丢失class内方法的缩进 + modified = self._align_indentation(original, modified) + # Verify original exists in file (should always be true since we read it) if original not in content: logger.error("Extracted original not found in file — this should not happen") @@ -573,6 +576,46 @@ class GeneratorExpert: ctx.generator_output = result return result + @staticmethod + def _align_indentation(original: str, modified: str) -> str: + """ + 让modified的基础缩进和original保持一致。 + 解决LLM生成class方法时丢失缩进的系统性bug: + original有4空格缩进(class内方法),但LLM返回0空格(顶层函数), + apply_patch替换后方法"跑出"class,导致IndentationError。 + """ + orig_lines = original.split("\n") + mod_lines = modified.split("\n") + if not orig_lines or not mod_lines: + return modified + + # 获取original第一个非空行的缩进 + orig_indent = 0 + for line in orig_lines: + if line.strip(): + orig_indent = len(line) - len(line.lstrip()) + break + + # 获取modified第一个非空行的缩进 + mod_indent = 0 + for line in mod_lines: + if line.strip(): + mod_indent = len(line) - len(line.lstrip()) + break + + diff = orig_indent - mod_indent + if diff <= 0: + return modified # modified缩进已经>=original,不需要调整 + + pad = " " * diff + aligned = [] + for line in mod_lines: + if line.strip(): # 非空行加缩进 + aligned.append(pad + line) + else: + aligned.append(line) + return "\n".join(aligned) + @staticmethod def _extract_function(content: str, func_name: str) -> Optional[str]: """Extract a complete function/method from file content by name.""" diff --git a/tests/test_audit_model.py b/tests/test_audit_model.py new file mode 100644 index 0000000..501d77f --- /dev/null +++ b/tests/test_audit_model.py @@ -0,0 +1,165 @@ +""" +Tests for audit logger, model commands, and _align_indentation fix. +""" + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +class TestAuditLogger(unittest.TestCase): + """Test AuditLogger write/list/show/clear.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.logs_dir = Path(self.tmpdir) / "logs" + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + @patch("kaiwu.audit.logger.LOGS_DIR") + def test_write_creates_log(self, mock_dir): + mock_dir.__class__ = type(self.logs_dir) + from kaiwu.audit.logger import AuditLogger + with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir): + logger = AuditLogger() + logger.start() + logger.log("gate", "locator_repair | 难度:easy") + logger.log("locator", "读取 test.py") + + # Mock context + ctx = MagicMock() + ctx.user_input = "修复login函数" + ctx.gate_result = {"expert_type": "locator_repair", "difficulty": "easy"} + ctx.generator_output = {"patches": [{"file": "test.py", "original": "old", "modified": "new"}]} + ctx.verifier_output = {"tests_passed": 3, "tests_total": 3} + ctx.retry_count = 0 + ctx.search_triggered = False + + logger.write(ctx, 5.2, True, "qwen3:8b") + + # Verify log file created + logs = list(self.logs_dir.glob("*.json")) + assert len(logs) == 1 + + data = json.loads(logs[0].read_text(encoding="utf-8")) + assert data["task"] == "修复login函数" + assert data["success"] is True + assert data["model"] == "qwen3:8b" + assert len(data["events"]) == 2 + assert data["files_modified"] == ["test.py"] + + @patch("kaiwu.audit.logger.LOGS_DIR") + def test_list_logs(self, mock_dir): + from kaiwu.audit.logger import list_logs + with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir): + self.logs_dir.mkdir(parents=True, exist_ok=True) + + # Write 3 log files with distinct names + for i in range(3): + record = {"task": f"task {i}", "success": True, "elapsed_s": 1.0, + "timestamp": f"2026-05-06T10:00:0{i}", "model": "test"} + (self.logs_dir / f"2026-05-06_10000{i}_codegen.json").write_text( + json.dumps(record), encoding="utf-8" + ) + + logs = list_logs(limit=10) + assert len(logs) == 3 + + @patch("kaiwu.audit.logger.LOGS_DIR") + def test_clear_logs(self, mock_dir): + from kaiwu.audit.logger import AuditLogger, clear_logs + with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir): + self.logs_dir.mkdir(parents=True, exist_ok=True) + (self.logs_dir / "test.json").write_text("{}", encoding="utf-8") + count = clear_logs() + assert count == 1 + assert len(list(self.logs_dir.glob("*.json"))) == 0 + + @patch("kaiwu.audit.logger.LOGS_DIR") + def test_max_logs_cleanup(self, mock_dir): + from kaiwu.audit.logger import AuditLogger, MAX_LOGS + with patch("kaiwu.audit.logger.LOGS_DIR", self.logs_dir): + self.logs_dir.mkdir(parents=True, exist_ok=True) + # Create MAX_LOGS + 5 files + for i in range(MAX_LOGS + 5): + (self.logs_dir / f"2026-01-01_{i:06d}_test.json").write_text("{}", encoding="utf-8") + + al = AuditLogger() + al._cleanup() + + remaining = list(self.logs_dir.glob("*.json")) + assert len(remaining) == MAX_LOGS + + +class TestAlignIndentation(unittest.TestCase): + """Test Generator._align_indentation — the class method indentation bug fix.""" + + def test_class_method_indent_fix(self): + from kaiwu.experts.generator import GeneratorExpert + # Original is class method (4-space indent) + original = " def login(self, user):\n return True" + # LLM returns without class indent + modified = "def login(self, user):\n if not user:\n return False\n return True" + + result = GeneratorExpert._align_indentation(original, modified) + # Should add 4 spaces to all non-empty lines + lines = result.split("\n") + assert lines[0] == " def login(self, user):" + assert lines[1] == " if not user:" + assert lines[2] == " return False" + assert lines[3] == " return True" + + def test_no_change_needed(self): + from kaiwu.experts.generator import GeneratorExpert + original = "def foo():\n return 1" + modified = "def foo():\n return 2" + result = GeneratorExpert._align_indentation(original, modified) + assert result == modified + + def test_already_more_indented(self): + from kaiwu.experts.generator import GeneratorExpert + original = "def foo():\n return 1" + modified = " def foo():\n return 2" + result = GeneratorExpert._align_indentation(original, modified) + # Should not change — modified already more indented + assert result == modified + + def test_empty_lines_preserved(self): + from kaiwu.experts.generator import GeneratorExpert + original = " def foo():\n\n return 1" + modified = "def foo():\n\n return 2" + result = GeneratorExpert._align_indentation(original, modified) + lines = result.split("\n") + assert lines[0] == " def foo():" + assert lines[1] == "" # Empty line stays empty + assert lines[2] == " return 2" + + def test_8_space_indent(self): + from kaiwu.experts.generator import GeneratorExpert + # Nested class method (8-space indent) + original = " def inner(self):\n pass" + modified = "def inner(self):\n return 42" + result = GeneratorExpert._align_indentation(original, modified) + assert result.startswith(" def inner(self):") + assert " return 42" in result + + +class TestHashlinePrompt(unittest.TestCase): + """Verify HASHLINE_PROMPT exists and has required format markers.""" + + def test_prompt_exists(self): + from kaiwu.experts.generator import HASHLINE_PROMPT + assert "{task_description}" in HASHLINE_PROMPT + assert "{anchored_code}" in HASHLINE_PROMPT + assert "EDIT" in HASHLINE_PROMPT + assert "DELETE" in HASHLINE_PROMPT + assert "INSERT_AFTER" in HASHLINE_PROMPT + + +if __name__ == "__main__": + unittest.main()