kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Environment, config, and provider diagnostics for ``/doctor``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from kiwimatecoder import __version__, audit, catalog, config
|
|
17
|
+
from kiwimatecoder.providers import ProviderConfig
|
|
18
|
+
from kiwimatecoder.session import Session
|
|
19
|
+
|
|
20
|
+
OK = "ok"
|
|
21
|
+
WARN = "warn"
|
|
22
|
+
FAIL = "fail"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Check:
|
|
27
|
+
"""One diagnostic result."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
status: str
|
|
31
|
+
detail: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git_branch(root: Path) -> str | None:
|
|
35
|
+
head = root / ".git" / "HEAD"
|
|
36
|
+
if not head.is_file():
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
ref = head.read_text(encoding="utf-8").strip()
|
|
40
|
+
except OSError:
|
|
41
|
+
return None
|
|
42
|
+
if ref.startswith("ref: refs/heads/"):
|
|
43
|
+
return ref.split("/")[-1]
|
|
44
|
+
return ref[:7] or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _package_version(name: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return importlib.metadata.version(name)
|
|
50
|
+
except importlib.metadata.PackageNotFoundError:
|
|
51
|
+
return "not installed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config_checks() -> list[Check]:
|
|
55
|
+
checks: list[Check] = []
|
|
56
|
+
try:
|
|
57
|
+
config_dir = config.ensure_config_dir()
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
return [Check("Config dir", FAIL, f"cannot create {config.CONFIG_DIR}: {exc}")]
|
|
60
|
+
|
|
61
|
+
writable = os.access(config_dir, os.W_OK)
|
|
62
|
+
checks.append(
|
|
63
|
+
Check(
|
|
64
|
+
"Config dir",
|
|
65
|
+
OK if writable else FAIL,
|
|
66
|
+
f"{config_dir}" + ("" if writable else " (not writable)"),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if config.CONFIG_FILE.is_file():
|
|
71
|
+
checks.append(Check("Global config", OK, str(config.CONFIG_FILE)))
|
|
72
|
+
else:
|
|
73
|
+
checks.append(Check("Global config", WARN, "not created yet (defaults in use)"))
|
|
74
|
+
|
|
75
|
+
project_path = config.project_config_path()
|
|
76
|
+
if project_path is not None:
|
|
77
|
+
checks.append(Check("Project config", OK, str(project_path)))
|
|
78
|
+
else:
|
|
79
|
+
candidate = Path.cwd() / config.PROJECT_CONFIG_NAME
|
|
80
|
+
checks.append(Check("Project config", OK, f"none (optional; {candidate} not found)"))
|
|
81
|
+
|
|
82
|
+
from kiwimatecoder import team as team_module
|
|
83
|
+
|
|
84
|
+
team_settings = config.get_team()
|
|
85
|
+
if not team_settings["policy_path"]:
|
|
86
|
+
checks.append(Check("Team policy", OK, "none (optional)"))
|
|
87
|
+
else:
|
|
88
|
+
issues = team_module.policy_issues()
|
|
89
|
+
errors = [issue for issue in issues if issue["level"] == "error"]
|
|
90
|
+
detail = (
|
|
91
|
+
f"{team_settings['policy_path']} "
|
|
92
|
+
f"({'enforced' if team_settings['enforce'] else 'advisory'})"
|
|
93
|
+
)
|
|
94
|
+
if errors:
|
|
95
|
+
status = FAIL
|
|
96
|
+
detail += f" — {errors[0]['message']}"
|
|
97
|
+
elif issues:
|
|
98
|
+
status = WARN
|
|
99
|
+
detail += f" — {issues[0]['message']}"
|
|
100
|
+
else:
|
|
101
|
+
status = OK
|
|
102
|
+
checks.append(Check("Team policy", status, detail))
|
|
103
|
+
|
|
104
|
+
sessions = config.ensure_config_dir() / "sessions"
|
|
105
|
+
count = len(list(sessions.glob("*.json"))) if sessions.is_dir() else 0
|
|
106
|
+
checks.append(Check("Saved sessions", OK, f"{count} in {sessions}"))
|
|
107
|
+
|
|
108
|
+
audit_path = audit.audit_log_path()
|
|
109
|
+
if audit_path.exists():
|
|
110
|
+
try:
|
|
111
|
+
size = audit_path.stat().st_size
|
|
112
|
+
except OSError:
|
|
113
|
+
size = 0
|
|
114
|
+
checks.append(Check("Audit log", OK, f"{audit_path} ({size} bytes)"))
|
|
115
|
+
else:
|
|
116
|
+
checks.append(Check("Audit log", OK, f"{audit_path} (not created yet)"))
|
|
117
|
+
|
|
118
|
+
from kiwimatecoder import telemetry as telemetry_module
|
|
119
|
+
|
|
120
|
+
telemetry_settings = config.get_telemetry()
|
|
121
|
+
crash_count = len(telemetry_module.crash_report_paths())
|
|
122
|
+
checks.append(
|
|
123
|
+
Check(
|
|
124
|
+
"Telemetry",
|
|
125
|
+
OK,
|
|
126
|
+
f"level {telemetry_settings['level']} "
|
|
127
|
+
f"({'on' if telemetry_settings['enabled'] else 'off'}) — "
|
|
128
|
+
f"log {telemetry_module.current_log_path()}; "
|
|
129
|
+
f"{crash_count} crash report(s)",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return checks
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _provider_checks(session: Session) -> list[Check]:
|
|
136
|
+
checks: list[Check] = []
|
|
137
|
+
provider: ProviderConfig = session.provider
|
|
138
|
+
checks.append(
|
|
139
|
+
Check("Provider", OK, f"{provider.id} ({provider.name}) — {provider.base_url}")
|
|
140
|
+
)
|
|
141
|
+
key_source = config.key_source(provider.id)
|
|
142
|
+
if provider.needs_key and key_source["origin"] == "missing":
|
|
143
|
+
checks.append(
|
|
144
|
+
Check(
|
|
145
|
+
"API key",
|
|
146
|
+
WARN,
|
|
147
|
+
f"missing — export {provider.key_env} or run "
|
|
148
|
+
f"`kiwimatecoder setup --provider {provider.id}`",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
checks.append(Check("API key", OK, config.describe_key(provider.id)))
|
|
153
|
+
|
|
154
|
+
if provider.is_local:
|
|
155
|
+
running = catalog.probe(provider, api_key=config.get_key(provider.id))
|
|
156
|
+
checks.append(
|
|
157
|
+
Check(
|
|
158
|
+
"Local server",
|
|
159
|
+
OK if running else WARN,
|
|
160
|
+
f"{provider.base_url} " + ("is responding" if running else "did not answer"),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
model_catalog = config.get_model_catalog(provider.id)
|
|
166
|
+
detail = f"{len(model_catalog.models)} models ({model_catalog.source})"
|
|
167
|
+
if model_catalog.error:
|
|
168
|
+
detail += f" — {model_catalog.error}"
|
|
169
|
+
checks.append(
|
|
170
|
+
Check(
|
|
171
|
+
"Model catalog",
|
|
172
|
+
WARN if model_catalog.error else OK,
|
|
173
|
+
detail,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
if not session.model:
|
|
177
|
+
checks.append(
|
|
178
|
+
Check(
|
|
179
|
+
"Current model",
|
|
180
|
+
WARN,
|
|
181
|
+
"none chosen — pick one with /model or "
|
|
182
|
+
f"`kiwimatecoder config model set <model> --provider {provider.id}`",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
elif model_catalog.models:
|
|
186
|
+
if session.model in model_catalog.models:
|
|
187
|
+
checks.append(Check("Current model", OK, session.model))
|
|
188
|
+
else:
|
|
189
|
+
checks.append(
|
|
190
|
+
Check(
|
|
191
|
+
"Current model",
|
|
192
|
+
WARN,
|
|
193
|
+
f"{session.model} is not in the current catalog (still usable by name)",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
197
|
+
checks.append(Check("Model catalog", FAIL, str(exc)))
|
|
198
|
+
|
|
199
|
+
for fallback in session.active_providers[1:]:
|
|
200
|
+
if not session.model_for(fallback.id):
|
|
201
|
+
checks.append(
|
|
202
|
+
Check(
|
|
203
|
+
"Fallback model",
|
|
204
|
+
WARN,
|
|
205
|
+
f"none chosen for {fallback.id}, so failover skips it — run "
|
|
206
|
+
f"`kiwimatecoder config model set <model> --provider {fallback.id}`",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
return checks
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _workspace_checks(session: Session) -> list[Check]:
|
|
213
|
+
root = session.workspace_root
|
|
214
|
+
checks: list[Check] = []
|
|
215
|
+
if not root.exists():
|
|
216
|
+
return [Check("Workspace", FAIL, f"{root} does not exist")]
|
|
217
|
+
writable = os.access(root, os.W_OK)
|
|
218
|
+
branch = _git_branch(root)
|
|
219
|
+
detail = str(root) + (f" (git:{branch})" if branch else "")
|
|
220
|
+
checks.append(Check("Workspace", OK if writable else WARN, detail))
|
|
221
|
+
checks.append(
|
|
222
|
+
Check(
|
|
223
|
+
"Permission mode",
|
|
224
|
+
OK,
|
|
225
|
+
f"{session.mode.value}; always-allowed: "
|
|
226
|
+
+ (", ".join(sorted(session.always_allowed)) or "none"),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
style = session.output_style
|
|
230
|
+
custom = "custom prompt set" if session.custom_system_prompt else "no custom prompt"
|
|
231
|
+
checks.append(Check("Output style", OK, f"{style} ({custom})"))
|
|
232
|
+
sampling = config.get_sampling()
|
|
233
|
+
checks.append(
|
|
234
|
+
Check(
|
|
235
|
+
"Sampling",
|
|
236
|
+
OK,
|
|
237
|
+
", ".join(f"{key}={value}" for key, value in sampling.items())
|
|
238
|
+
or "provider defaults",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return checks
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def run_checks(session: Session) -> list[Check]:
|
|
245
|
+
"""Collect all diagnostic checks, never raising."""
|
|
246
|
+
checks: list[Check] = [
|
|
247
|
+
Check(
|
|
248
|
+
"Version",
|
|
249
|
+
OK,
|
|
250
|
+
f"kiwimatecoder {__version__} (Python {platform.python_version()}, "
|
|
251
|
+
f"{sys.platform})",
|
|
252
|
+
),
|
|
253
|
+
Check(
|
|
254
|
+
"Dependencies",
|
|
255
|
+
OK,
|
|
256
|
+
"httpx " + _package_version("httpx")
|
|
257
|
+
+ ", rich " + _package_version("rich")
|
|
258
|
+
+ ", prompt_toolkit " + _package_version("prompt_toolkit")
|
|
259
|
+
+ ", typer " + _package_version("typer"),
|
|
260
|
+
),
|
|
261
|
+
]
|
|
262
|
+
checks.extend(_config_checks())
|
|
263
|
+
try:
|
|
264
|
+
checks.extend(_provider_checks(session))
|
|
265
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
266
|
+
checks.append(Check("Provider", FAIL, str(exc)))
|
|
267
|
+
checks.extend(_workspace_checks(session))
|
|
268
|
+
return checks
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_STATUS_STYLE = {OK: "green", WARN: "yellow", FAIL: "red"}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def render(checks: list[Check], console: Console) -> None:
|
|
275
|
+
"""Print checks as a table and a one-line summary."""
|
|
276
|
+
table = Table(title="KiwiMateCoder diagnostics", show_header=True)
|
|
277
|
+
table.add_column("Check", style="cyan", no_wrap=True)
|
|
278
|
+
table.add_column("Status")
|
|
279
|
+
table.add_column("Details")
|
|
280
|
+
for check in checks:
|
|
281
|
+
style = _STATUS_STYLE.get(check.status, "white")
|
|
282
|
+
table.add_row(
|
|
283
|
+
escape(check.name),
|
|
284
|
+
f"[{style}]{check.status}[/{style}]",
|
|
285
|
+
escape(check.detail),
|
|
286
|
+
)
|
|
287
|
+
console.print(table)
|
|
288
|
+
failures = sum(1 for check in checks if check.status == FAIL)
|
|
289
|
+
warnings = sum(1 for check in checks if check.status == WARN)
|
|
290
|
+
if failures:
|
|
291
|
+
console.print(f"[red]{failures} check(s) failed.[/red]")
|
|
292
|
+
elif warnings:
|
|
293
|
+
console.print(f"[yellow]{warnings} warning(s); nothing critical.[/yellow]")
|
|
294
|
+
else:
|
|
295
|
+
console.print("[green]All checks passed.[/green]")
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Pure extractors for notebooks, PDFs, and docx files.
|
|
2
|
+
|
|
3
|
+
``read_file`` delegates to :func:`extract_document` before its binary check so
|
|
4
|
+
structured documents are surfaced as text instead of being rejected. Every
|
|
5
|
+
extractor is best-effort and never raises: failures come back as a clear,
|
|
6
|
+
model-readable string. Output is capped at :data:`MAX_CHARS` with a truncation
|
|
7
|
+
note.
|
|
8
|
+
|
|
9
|
+
PDF support degrades gracefully: the ``pdftotext`` CLI (poppler) is used when
|
|
10
|
+
available, otherwise a conservative stdlib fallback decompresses FlateDecode
|
|
11
|
+
streams and pulls text out of ``BT``/``ET`` blocks. If neither yields text, the
|
|
12
|
+
result explains how to install poppler.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import zipfile
|
|
24
|
+
import zlib
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
MAX_CHARS = 200_000
|
|
29
|
+
DOCUMENT_SUFFIXES = (".ipynb", ".pdf", ".docx")
|
|
30
|
+
PDFTOTEXT_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
PDF_INSTALL_HINT = (
|
|
33
|
+
"Error: no extractable text found in this PDF. Install pdftotext (poppler) "
|
|
34
|
+
"for reliable PDF text extraction, or convert the pages to images and "
|
|
35
|
+
"attach them with view_image."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate(text: str, source: str) -> str:
|
|
40
|
+
if len(text) <= MAX_CHARS:
|
|
41
|
+
return text
|
|
42
|
+
return (
|
|
43
|
+
text[:MAX_CHARS]
|
|
44
|
+
+ f"\n... [truncated: {source} exceeds {MAX_CHARS:,} characters]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Jupyter notebooks (.ipynb)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _join_text(value: Any) -> str:
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return "".join(str(part) for part in value)
|
|
56
|
+
return str(value or "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _image_payload_size(value: Any) -> int:
|
|
60
|
+
if isinstance(value, (bytes, bytearray)):
|
|
61
|
+
return len(value)
|
|
62
|
+
text = str(value)
|
|
63
|
+
try:
|
|
64
|
+
return len(base64.b64decode(text, validate=False))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return len(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_notebook_output(output: dict[str, Any]) -> list[str]:
|
|
70
|
+
output_type = str(output.get("output_type") or "")
|
|
71
|
+
if output_type == "stream":
|
|
72
|
+
text = _join_text(output.get("text")).rstrip("\n")
|
|
73
|
+
return ["Output:", text] if text else []
|
|
74
|
+
if output_type == "error":
|
|
75
|
+
name = str(output.get("ename") or "Error")
|
|
76
|
+
value = str(output.get("evalue") or "")
|
|
77
|
+
return [f"Output error: {name}: {value}".rstrip(": ")]
|
|
78
|
+
data = output.get("data")
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
return []
|
|
81
|
+
rendered: list[str] = []
|
|
82
|
+
text_plain = data.get("text/plain")
|
|
83
|
+
if text_plain:
|
|
84
|
+
rendered.append(_join_text(text_plain).rstrip("\n"))
|
|
85
|
+
for mime, value in data.items():
|
|
86
|
+
if not isinstance(mime, str) or not mime.startswith("image/"):
|
|
87
|
+
continue
|
|
88
|
+
rendered.append(f"[{mime} output, {_image_payload_size(value)} bytes]")
|
|
89
|
+
return ["Output:", *rendered] if rendered else []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_notebook(path: str | Path) -> str:
|
|
93
|
+
"""Render a ``.ipynb`` file as Markdown-ish text with cell outputs."""
|
|
94
|
+
notebook_path = Path(path)
|
|
95
|
+
try:
|
|
96
|
+
raw = notebook_path.read_text(encoding="utf-8")
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
return f"Error: could not read notebook '{notebook_path}': {exc}"
|
|
99
|
+
except UnicodeDecodeError as exc:
|
|
100
|
+
return f"Error: '{notebook_path.name}' is not readable UTF-8 text: {exc}"
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(raw)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
return f"Error: '{notebook_path.name}' is not valid notebook JSON: {exc}"
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return (
|
|
107
|
+
f"Error: '{notebook_path.name}' is not a notebook "
|
|
108
|
+
"(expected a JSON object)."
|
|
109
|
+
)
|
|
110
|
+
cells = data.get("cells")
|
|
111
|
+
if not isinstance(cells, list):
|
|
112
|
+
return f"Error: '{notebook_path.name}' has no 'cells' list."
|
|
113
|
+
|
|
114
|
+
lines: list[str] = []
|
|
115
|
+
for index, cell in enumerate(cells, 1):
|
|
116
|
+
if not isinstance(cell, dict):
|
|
117
|
+
continue
|
|
118
|
+
cell_type = str(cell.get("cell_type") or "unknown")
|
|
119
|
+
lines.append(f"## Cell {index} ({cell_type})")
|
|
120
|
+
source = _join_text(cell.get("source")).rstrip("\n")
|
|
121
|
+
if source:
|
|
122
|
+
lines.append(source)
|
|
123
|
+
for output in cell.get("outputs") or []:
|
|
124
|
+
if isinstance(output, dict):
|
|
125
|
+
lines.extend(_format_notebook_output(output))
|
|
126
|
+
body = "\n".join(lines).rstrip() + "\n" if lines else ""
|
|
127
|
+
return _truncate(body, notebook_path.name)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Word documents (.docx)
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_docx(path: str | Path) -> str:
|
|
136
|
+
"""Extract text from ``word/document.xml`` inside a ``.docx`` archive."""
|
|
137
|
+
docx_path = Path(path)
|
|
138
|
+
try:
|
|
139
|
+
with zipfile.ZipFile(docx_path) as archive:
|
|
140
|
+
try:
|
|
141
|
+
xml_bytes = archive.read("word/document.xml")
|
|
142
|
+
except KeyError:
|
|
143
|
+
return (
|
|
144
|
+
f"Error: '{docx_path.name}' is not a valid docx file "
|
|
145
|
+
"(missing word/document.xml)."
|
|
146
|
+
)
|
|
147
|
+
except zipfile.BadZipFile:
|
|
148
|
+
return f"Error: '{docx_path.name}' is not a valid docx file (not a zip)."
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
return f"Error: could not read docx '{docx_path.name}': {exc}"
|
|
151
|
+
try:
|
|
152
|
+
xml_text = xml_bytes.decode("utf-8")
|
|
153
|
+
except UnicodeDecodeError as exc:
|
|
154
|
+
return f"Error: '{docx_path.name}' has unreadable document.xml: {exc}"
|
|
155
|
+
|
|
156
|
+
# Preserve structure before stripping tags: paragraphs and line breaks
|
|
157
|
+
# become newlines, tabs become tabs. Entities are decoded last so text like
|
|
158
|
+
# ``<w:p>`` cannot turn into a fake tag.
|
|
159
|
+
xml_text = re.sub(r"</w:p\s*>", "\n", xml_text)
|
|
160
|
+
xml_text = re.sub(r"<w:br\s*/?>", "\n", xml_text)
|
|
161
|
+
xml_text = re.sub(r"<w:tab\s*/?>", "\t", xml_text)
|
|
162
|
+
text = re.sub(r"<[^>]+>", "", xml_text)
|
|
163
|
+
text = html.unescape(text)
|
|
164
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
165
|
+
body = "\n".join(lines).strip()
|
|
166
|
+
return _truncate(body + "\n" if body else "", docx_path.name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# PDFs
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
_PDF_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
|
|
174
|
+
_PDF_STRING_RE = re.compile(r"\((?:\\.|[^\\()])*\)", re.DOTALL)
|
|
175
|
+
_PDF_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _inflate(data: bytes) -> bytes | None:
|
|
179
|
+
for wbits in (zlib.MAX_WBITS, -zlib.MAX_WBITS):
|
|
180
|
+
try:
|
|
181
|
+
return zlib.decompress(data, wbits)
|
|
182
|
+
except zlib.error:
|
|
183
|
+
continue
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _unescape_pdf_string(value: str) -> str:
|
|
188
|
+
result: list[str] = []
|
|
189
|
+
index = 0
|
|
190
|
+
length = len(value)
|
|
191
|
+
while index < length:
|
|
192
|
+
char = value[index]
|
|
193
|
+
if char != "\\" or index + 1 >= length:
|
|
194
|
+
result.append(char)
|
|
195
|
+
index += 1
|
|
196
|
+
continue
|
|
197
|
+
nxt = value[index + 1]
|
|
198
|
+
if nxt in "\\()":
|
|
199
|
+
result.append(nxt)
|
|
200
|
+
index += 2
|
|
201
|
+
elif nxt in _PDF_ESCAPES:
|
|
202
|
+
result.append(_PDF_ESCAPES[nxt])
|
|
203
|
+
index += 2
|
|
204
|
+
elif nxt.isdigit():
|
|
205
|
+
digits = nxt
|
|
206
|
+
index += 2
|
|
207
|
+
while index < length and len(digits) < 3 and value[index].isdigit():
|
|
208
|
+
digits += value[index]
|
|
209
|
+
index += 1
|
|
210
|
+
result.append(chr(int(digits, 8) & 0xFF))
|
|
211
|
+
else:
|
|
212
|
+
index += 2
|
|
213
|
+
return "".join(result)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_pdf_text(stream: bytes) -> str:
|
|
217
|
+
"""Pull literal strings out of ``BT``/``ET`` blocks of a content stream."""
|
|
218
|
+
text = stream.decode("latin-1", "replace")
|
|
219
|
+
lines: list[str] = []
|
|
220
|
+
for block in re.findall(r"BT(.*?)ET", text, flags=re.DOTALL):
|
|
221
|
+
pieces = [
|
|
222
|
+
_unescape_pdf_string(match.group(0)[1:-1])
|
|
223
|
+
for match in _PDF_STRING_RE.finditer(block)
|
|
224
|
+
]
|
|
225
|
+
joined = " ".join(piece for piece in pieces if piece)
|
|
226
|
+
if joined:
|
|
227
|
+
lines.append(joined)
|
|
228
|
+
return "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _pdf_fallback(data: bytes) -> str:
|
|
232
|
+
"""Conservative stdlib extraction: inflate streams, then read BT/ET text."""
|
|
233
|
+
chunks: list[str] = []
|
|
234
|
+
for match in _PDF_STREAM_RE.finditer(data):
|
|
235
|
+
raw = match.group(1)
|
|
236
|
+
decoded = _inflate(raw)
|
|
237
|
+
if decoded is None:
|
|
238
|
+
decoded = raw
|
|
239
|
+
chunk = _extract_pdf_text(decoded)
|
|
240
|
+
if chunk:
|
|
241
|
+
chunks.append(chunk)
|
|
242
|
+
return "\n".join(chunks).strip()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def extract_pdf(path: str | Path) -> str:
|
|
246
|
+
"""Extract PDF text via ``pdftotext`` when present, else the fallback.
|
|
247
|
+
|
|
248
|
+
Never raises; returns :data:`PDF_INSTALL_HINT` when nothing parseable is
|
|
249
|
+
found.
|
|
250
|
+
"""
|
|
251
|
+
pdf_path = Path(path)
|
|
252
|
+
executable = shutil.which("pdftotext")
|
|
253
|
+
if executable:
|
|
254
|
+
try:
|
|
255
|
+
completed = subprocess.run(
|
|
256
|
+
# PDF first, "-" as the output file means stdout. (Reversing
|
|
257
|
+
# these would make pdftotext read stdin and overwrite the PDF.)
|
|
258
|
+
[executable, str(pdf_path), "-"],
|
|
259
|
+
capture_output=True,
|
|
260
|
+
timeout=PDFTOTEXT_TIMEOUT,
|
|
261
|
+
check=False,
|
|
262
|
+
)
|
|
263
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
264
|
+
return f"Error: pdftotext failed: {exc}"
|
|
265
|
+
if completed.returncode == 0:
|
|
266
|
+
stdout = completed.stdout.decode("utf-8", "replace")
|
|
267
|
+
if stdout.strip():
|
|
268
|
+
return _truncate(stdout, pdf_path.name)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
data = pdf_path.read_bytes()
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
return f"Error: could not read PDF '{pdf_path.name}': {exc}"
|
|
274
|
+
text = _pdf_fallback(data)
|
|
275
|
+
if text.strip():
|
|
276
|
+
return _truncate(text + "\n", pdf_path.name)
|
|
277
|
+
return PDF_INSTALL_HINT
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Dispatch
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def extract_document(path: str | Path) -> tuple[str, str] | None:
|
|
286
|
+
"""Dispatch by suffix, returning ``(content, kind)`` or None.
|
|
287
|
+
|
|
288
|
+
``kind`` is one of ``"notebook"``, ``"pdf"``, or ``"docx"``. Content may be
|
|
289
|
+
an error string when the file is malformed; callers surface it as-is.
|
|
290
|
+
"""
|
|
291
|
+
document_path = Path(path)
|
|
292
|
+
suffix = document_path.suffix.lower()
|
|
293
|
+
if suffix == ".ipynb":
|
|
294
|
+
return extract_notebook(document_path), "notebook"
|
|
295
|
+
if suffix == ".pdf":
|
|
296
|
+
return extract_pdf(document_path), "pdf"
|
|
297
|
+
if suffix == ".docx":
|
|
298
|
+
return extract_docx(document_path), "docx"
|
|
299
|
+
return None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Declarative eval harness for prompt/tool regressions."""
|
|
2
|
+
|
|
3
|
+
from kiwimatecoder.evals.cases import (
|
|
4
|
+
DEFAULT_CASES_DIR,
|
|
5
|
+
CaseError,
|
|
6
|
+
EvalCase,
|
|
7
|
+
Expectation,
|
|
8
|
+
discover_cases,
|
|
9
|
+
load_case,
|
|
10
|
+
parse_case,
|
|
11
|
+
resolve_cases_dir,
|
|
12
|
+
)
|
|
13
|
+
from kiwimatecoder.evals.runner import (
|
|
14
|
+
EvalReport,
|
|
15
|
+
EvalResult,
|
|
16
|
+
EvalTimeout,
|
|
17
|
+
evaluate_case,
|
|
18
|
+
materialize_case,
|
|
19
|
+
run_case,
|
|
20
|
+
run_suite,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_CASES_DIR",
|
|
25
|
+
"CaseError",
|
|
26
|
+
"EvalCase",
|
|
27
|
+
"EvalReport",
|
|
28
|
+
"EvalResult",
|
|
29
|
+
"EvalTimeout",
|
|
30
|
+
"Expectation",
|
|
31
|
+
"discover_cases",
|
|
32
|
+
"evaluate_case",
|
|
33
|
+
"load_case",
|
|
34
|
+
"materialize_case",
|
|
35
|
+
"parse_case",
|
|
36
|
+
"resolve_cases_dir",
|
|
37
|
+
"run_case",
|
|
38
|
+
"run_suite",
|
|
39
|
+
]
|