markback 0.1.2__py3-none-any.whl → 0.1.5__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.
- markback/cli.py +83 -396
- markback/linter.py +32 -7
- markback/parser.py +6 -2
- markback/types.py +34 -9
- markback/writer.py +10 -3
- {markback-0.1.2.dist-info → markback-0.1.5.dist-info}/METADATA +8 -1
- markback-0.1.5.dist-info/RECORD +14 -0
- markback-0.1.5.dist-info/entry_points.txt +3 -0
- markback-0.1.2.dist-info/RECORD +0 -14
- markback-0.1.2.dist-info/entry_points.txt +0 -2
- {markback-0.1.2.dist-info → markback-0.1.5.dist-info}/WHEEL +0 -0
- {markback-0.1.2.dist-info → markback-0.1.5.dist-info}/licenses/LICENSE +0 -0
markback/cli.py
CHANGED
|
@@ -1,435 +1,122 @@
|
|
|
1
1
|
"""MarkBack command-line interface."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
4
7
|
import sys
|
|
5
8
|
from pathlib import Path
|
|
6
|
-
from typing import Annotated
|
|
9
|
+
from typing import Annotated
|
|
7
10
|
|
|
8
11
|
import typer
|
|
9
12
|
from rich.console import Console
|
|
10
|
-
from rich.table import Table
|
|
11
13
|
|
|
12
|
-
from .
|
|
13
|
-
from .linter import format_diagnostics, lint_files, summarize_results
|
|
14
|
-
from .parser import parse_file, parse_string
|
|
15
|
-
from .types import Severity, parse_feedback
|
|
16
|
-
from .writer import OutputMode, normalize_file, write_file, write_records_multi
|
|
14
|
+
from .writer import write_file
|
|
17
15
|
|
|
18
|
-
app = typer.Typer(
|
|
19
|
-
name="markback",
|
|
20
|
-
help="MarkBack: A compact format for content + feedback",
|
|
21
|
-
no_args_is_help=True,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
console = Console()
|
|
25
16
|
err_console = Console(stderr=True)
|
|
26
17
|
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Path,
|
|
32
|
-
typer.Argument(help="Path to create .env file"),
|
|
33
|
-
] = Path(".env"),
|
|
34
|
-
force: Annotated[
|
|
35
|
-
bool,
|
|
36
|
-
typer.Option("--force", "-f", help="Overwrite existing file"),
|
|
37
|
-
] = False,
|
|
38
|
-
):
|
|
39
|
-
"""Initialize a .env configuration file."""
|
|
40
|
-
if init_env(path, force=force):
|
|
41
|
-
console.print(f"[green]Created {path}[/green]")
|
|
42
|
-
else:
|
|
43
|
-
console.print(f"[yellow]{path} already exists. Use --force to overwrite.[/yellow]")
|
|
44
|
-
raise typer.Exit(1)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@app.command()
|
|
48
|
-
def lint(
|
|
49
|
-
paths: Annotated[
|
|
50
|
-
list[Path],
|
|
51
|
-
typer.Argument(help="Files or directories to lint"),
|
|
52
|
-
],
|
|
53
|
-
output_json: Annotated[
|
|
54
|
-
bool,
|
|
55
|
-
typer.Option("--json", "-j", help="Output as JSON"),
|
|
56
|
-
] = False,
|
|
57
|
-
no_source_check: Annotated[
|
|
58
|
-
bool,
|
|
59
|
-
typer.Option("--no-source-check", help="Skip checking if @source files exist"),
|
|
60
|
-
] = False,
|
|
61
|
-
no_canonical_check: Annotated[
|
|
62
|
-
bool,
|
|
63
|
-
typer.Option("--no-canonical-check", help="Skip canonical format check"),
|
|
64
|
-
] = False,
|
|
65
|
-
):
|
|
66
|
-
"""Lint MarkBack files."""
|
|
67
|
-
results = lint_files(
|
|
68
|
-
paths,
|
|
69
|
-
check_sources=not no_source_check,
|
|
70
|
-
check_canonical=not no_canonical_check,
|
|
71
|
-
)
|
|
19
|
+
def get_mb_path(target: Path) -> Path:
|
|
20
|
+
"""Get the .mb file path for a target file."""
|
|
21
|
+
return target.with_suffix(target.suffix + ".mb")
|
|
72
22
|
|
|
73
|
-
summary = summarize_results(results)
|
|
74
23
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
24
|
+
def get_feedback_path(directory: Path) -> Path:
|
|
25
|
+
"""Get a non-colliding feedback.mb path in the given directory."""
|
|
26
|
+
candidate = directory / "feedback.mb"
|
|
27
|
+
if not candidate.exists():
|
|
28
|
+
return candidate
|
|
29
|
+
counter = 1
|
|
30
|
+
while True:
|
|
31
|
+
candidate = directory / f"feedback-{counter}.mb"
|
|
32
|
+
if not candidate.exists():
|
|
33
|
+
return candidate
|
|
34
|
+
counter += 1
|
|
79
35
|
|
|
80
|
-
if output_json:
|
|
81
|
-
output = {
|
|
82
|
-
"summary": summary,
|
|
83
|
-
"diagnostics": [d.to_dict() for d in all_diagnostics],
|
|
84
|
-
}
|
|
85
|
-
console.print(json.dumps(output, indent=2))
|
|
86
|
-
else:
|
|
87
|
-
# Print diagnostics
|
|
88
|
-
for d in all_diagnostics:
|
|
89
|
-
if d.severity == Severity.ERROR:
|
|
90
|
-
err_console.print(f"[red]{d}[/red]")
|
|
91
|
-
else:
|
|
92
|
-
err_console.print(f"[yellow]{d}[/yellow]")
|
|
93
36
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
console.print(f"Records: {summary['records']}")
|
|
98
|
-
console.print(f"Errors: {summary['errors']}")
|
|
99
|
-
console.print(f"Warnings: {summary['warnings']}")
|
|
37
|
+
def collect_glob(pattern: str) -> None:
|
|
38
|
+
"""Expand a glob pattern and write all matches into a single .mb file."""
|
|
39
|
+
from .types import Record, SourceRef
|
|
100
40
|
|
|
101
|
-
|
|
102
|
-
if
|
|
41
|
+
matches = sorted(glob.glob(pattern))
|
|
42
|
+
if not matches:
|
|
43
|
+
err_console.print(f"[red]No files match pattern: {pattern}[/red]")
|
|
103
44
|
raise typer.Exit(1)
|
|
104
45
|
|
|
46
|
+
records = [Record(source=SourceRef(f), feedback="") for f in matches]
|
|
105
47
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
typer.Argument(help="Input MarkBack file"),
|
|
111
|
-
],
|
|
112
|
-
output_path: Annotated[
|
|
113
|
-
Optional[Path],
|
|
114
|
-
typer.Argument(help="Output file (omit for in-place)"),
|
|
115
|
-
] = None,
|
|
116
|
-
in_place: Annotated[
|
|
117
|
-
bool,
|
|
118
|
-
typer.Option("--in-place", "-i", help="Modify input file in place"),
|
|
119
|
-
] = False,
|
|
120
|
-
):
|
|
121
|
-
"""Normalize a MarkBack file to canonical format."""
|
|
122
|
-
try:
|
|
123
|
-
content = normalize_file(
|
|
124
|
-
input_path,
|
|
125
|
-
output_path=output_path,
|
|
126
|
-
in_place=in_place or (output_path is None),
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
if output_path:
|
|
130
|
-
console.print(f"[green]Wrote {output_path}[/green]")
|
|
131
|
-
elif in_place:
|
|
132
|
-
console.print(f"[green]Normalized {input_path}[/green]")
|
|
133
|
-
else:
|
|
134
|
-
console.print(content)
|
|
135
|
-
|
|
136
|
-
except ValueError as e:
|
|
137
|
-
err_console.print(f"[red]Error: {e}[/red]")
|
|
138
|
-
raise typer.Exit(1)
|
|
48
|
+
# Determine output directory from the pattern's parent, defaulting to cwd
|
|
49
|
+
pattern_parent = Path(pattern).parent
|
|
50
|
+
output_dir = pattern_parent if pattern_parent != Path("") else Path(".")
|
|
51
|
+
output_path = get_feedback_path(output_dir)
|
|
139
52
|
|
|
53
|
+
write_file(output_path, records)
|
|
54
|
+
err_console.print(f"Created {output_path} with {len(records)} source(s)")
|
|
55
|
+
open_editor(output_path)
|
|
140
56
|
|
|
141
|
-
@app.command("list")
|
|
142
|
-
def list_records(
|
|
143
|
-
paths: Annotated[
|
|
144
|
-
list[Path],
|
|
145
|
-
typer.Argument(help="Files or directories to list"),
|
|
146
|
-
],
|
|
147
|
-
output_json: Annotated[
|
|
148
|
-
bool,
|
|
149
|
-
typer.Option("--json", "-j", help="Output as JSON"),
|
|
150
|
-
] = False,
|
|
151
|
-
):
|
|
152
|
-
"""List records in MarkBack files."""
|
|
153
|
-
all_records = []
|
|
154
57
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
result = parse_file(mb_file)
|
|
159
|
-
for record in result.records:
|
|
160
|
-
all_records.append((mb_file, record))
|
|
161
|
-
else:
|
|
162
|
-
result = parse_file(path)
|
|
163
|
-
for record in result.records:
|
|
164
|
-
all_records.append((path, record))
|
|
58
|
+
def open_editor(path: Path) -> None:
|
|
59
|
+
"""Open the file in the user's editor."""
|
|
60
|
+
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
|
|
165
61
|
|
|
166
|
-
if
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"source": str(record.source) if record.source else None,
|
|
173
|
-
"feedback": record.feedback,
|
|
174
|
-
"has_content": record.has_inline_content(),
|
|
175
|
-
})
|
|
176
|
-
console.print(json.dumps(output, indent=2))
|
|
62
|
+
if editor:
|
|
63
|
+
subprocess.run([editor, str(path)])
|
|
64
|
+
elif sys.platform == "win32":
|
|
65
|
+
os.startfile(path)
|
|
66
|
+
elif sys.platform == "darwin":
|
|
67
|
+
subprocess.run(["open", str(path)])
|
|
177
68
|
else:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
for
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
table.add_row(uri, source, feedback)
|
|
192
|
-
|
|
193
|
-
console.print(table)
|
|
194
|
-
console.print(f"\nTotal: {len(all_records)} records")
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
@app.command()
|
|
198
|
-
def convert(
|
|
199
|
-
input_path: Annotated[
|
|
200
|
-
Path,
|
|
201
|
-
typer.Argument(help="Input MarkBack file or directory"),
|
|
202
|
-
],
|
|
203
|
-
output_path: Annotated[
|
|
204
|
-
Path,
|
|
205
|
-
typer.Argument(help="Output file or directory"),
|
|
206
|
-
],
|
|
207
|
-
to: Annotated[
|
|
208
|
-
str,
|
|
209
|
-
typer.Option("--to", "-t", help="Output format: single, multi, compact, paired"),
|
|
210
|
-
] = "multi",
|
|
211
|
-
):
|
|
212
|
-
"""Convert between MarkBack storage modes."""
|
|
213
|
-
# Parse input
|
|
214
|
-
if input_path.is_dir():
|
|
215
|
-
from .parser import parse_directory
|
|
216
|
-
result = parse_directory(input_path)
|
|
217
|
-
else:
|
|
218
|
-
result = parse_file(input_path)
|
|
219
|
-
|
|
220
|
-
if result.has_errors:
|
|
221
|
-
err_console.print("[red]Cannot convert file with errors[/red]")
|
|
222
|
-
for d in result.diagnostics:
|
|
223
|
-
if d.severity == Severity.ERROR:
|
|
224
|
-
err_console.print(f"[red]{d}[/red]")
|
|
225
|
-
raise typer.Exit(1)
|
|
226
|
-
|
|
227
|
-
records = result.records
|
|
228
|
-
|
|
229
|
-
# Convert to output format
|
|
230
|
-
mode_map = {
|
|
231
|
-
"single": OutputMode.SINGLE,
|
|
232
|
-
"multi": OutputMode.MULTI,
|
|
233
|
-
"compact": OutputMode.COMPACT,
|
|
234
|
-
"paired": OutputMode.PAIRED,
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if to not in mode_map:
|
|
238
|
-
err_console.print(f"[red]Unknown format: {to}. Use: single, multi, compact, paired[/red]")
|
|
69
|
+
# Try xdg-open first, then fall back to common editors
|
|
70
|
+
if shutil.which("xdg-open"):
|
|
71
|
+
result = subprocess.run(["xdg-open", str(path)], capture_output=True)
|
|
72
|
+
if result.returncode == 0:
|
|
73
|
+
return
|
|
74
|
+
for fallback in ("nano", "vi"):
|
|
75
|
+
if shutil.which(fallback):
|
|
76
|
+
subprocess.run([fallback, str(path)])
|
|
77
|
+
return
|
|
78
|
+
err_console.print(
|
|
79
|
+
f"[red]No editor found. Set the EDITOR environment variable, e.g.:[/red]\n"
|
|
80
|
+
f" export EDITOR=nano"
|
|
81
|
+
)
|
|
239
82
|
raise typer.Exit(1)
|
|
240
83
|
|
|
241
|
-
mode = mode_map[to]
|
|
242
|
-
|
|
243
|
-
if mode == OutputMode.PAIRED:
|
|
244
|
-
# Paired mode: create output directory with label files
|
|
245
|
-
output_path.mkdir(parents=True, exist_ok=True)
|
|
246
|
-
from .writer import write_paired_files
|
|
247
|
-
for i, record in enumerate(records):
|
|
248
|
-
# Determine filename from URI or source or index
|
|
249
|
-
if record.source:
|
|
250
|
-
basename = Path(str(record.source)).stem
|
|
251
|
-
elif record.uri:
|
|
252
|
-
basename = record.uri.split("/")[-1].split(":")[-1]
|
|
253
|
-
else:
|
|
254
|
-
basename = f"record_{i:04d}"
|
|
255
|
-
|
|
256
|
-
label_path = output_path / f"{basename}.label.txt"
|
|
257
|
-
write_paired_files(label_path, None, record)
|
|
258
|
-
|
|
259
|
-
console.print(f"[green]Wrote {len(records)} label files to {output_path}[/green]")
|
|
260
|
-
|
|
261
|
-
elif mode == OutputMode.SINGLE:
|
|
262
|
-
if len(records) != 1:
|
|
263
|
-
err_console.print(f"[red]Single mode requires exactly 1 record, got {len(records)}[/red]")
|
|
264
|
-
raise typer.Exit(1)
|
|
265
|
-
write_file(output_path, records, mode=mode)
|
|
266
|
-
console.print(f"[green]Wrote {output_path}[/green]")
|
|
267
|
-
|
|
268
|
-
else:
|
|
269
|
-
write_file(output_path, records, mode=mode)
|
|
270
|
-
console.print(f"[green]Wrote {len(records)} records to {output_path}[/green]")
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# Workflow subcommand group
|
|
274
|
-
workflow_app = typer.Typer(
|
|
275
|
-
name="workflow",
|
|
276
|
-
help="Editor/Operator LLM workflow commands",
|
|
277
|
-
)
|
|
278
|
-
app.add_typer(workflow_app, name="workflow")
|
|
279
84
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def workflow_run(
|
|
283
|
-
dataset: Annotated[
|
|
284
|
-
Path,
|
|
285
|
-
typer.Argument(help="Path to MarkBack dataset file or directory"),
|
|
286
|
-
],
|
|
287
|
-
prompt: Annotated[
|
|
85
|
+
def main(
|
|
86
|
+
target: Annotated[
|
|
288
87
|
str,
|
|
289
|
-
typer.
|
|
290
|
-
] = "",
|
|
291
|
-
output: Annotated[
|
|
292
|
-
Path,
|
|
293
|
-
typer.Option("--output", "-o", help="Output file for results"),
|
|
294
|
-
] = Path("workflow_result.json"),
|
|
295
|
-
env_file: Annotated[
|
|
296
|
-
Optional[Path],
|
|
297
|
-
typer.Option("--env", "-e", help="Path to .env file"),
|
|
298
|
-
] = None,
|
|
299
|
-
):
|
|
300
|
-
"""Run the editor/operator workflow on a dataset."""
|
|
301
|
-
from .workflow import run_workflow, save_workflow_result
|
|
302
|
-
|
|
303
|
-
# Load config
|
|
304
|
-
config = load_config(env_file)
|
|
305
|
-
|
|
306
|
-
# Validate config
|
|
307
|
-
issues = validate_config(config)
|
|
308
|
-
if issues:
|
|
309
|
-
for issue in issues:
|
|
310
|
-
err_console.print(f"[yellow]Config warning: {issue}[/yellow]")
|
|
311
|
-
|
|
312
|
-
if config.editor is None or config.operator is None:
|
|
313
|
-
err_console.print("[red]Editor and Operator LLMs must be configured in .env[/red]")
|
|
314
|
-
raise typer.Exit(1)
|
|
315
|
-
|
|
316
|
-
# Load initial prompt
|
|
317
|
-
initial_prompt = prompt
|
|
318
|
-
if prompt and Path(prompt).exists():
|
|
319
|
-
initial_prompt = Path(prompt).read_text(encoding="utf-8")
|
|
320
|
-
|
|
321
|
-
# Load dataset
|
|
322
|
-
if dataset.is_dir():
|
|
323
|
-
from .parser import parse_directory
|
|
324
|
-
result = parse_directory(dataset)
|
|
325
|
-
else:
|
|
326
|
-
result = parse_file(dataset)
|
|
327
|
-
|
|
328
|
-
if result.has_errors:
|
|
329
|
-
err_console.print("[red]Dataset has errors:[/red]")
|
|
330
|
-
for d in result.diagnostics:
|
|
331
|
-
if d.severity == Severity.ERROR:
|
|
332
|
-
err_console.print(f"[red]{d}[/red]")
|
|
333
|
-
raise typer.Exit(1)
|
|
334
|
-
|
|
335
|
-
if not result.records:
|
|
336
|
-
err_console.print("[red]No records found in dataset[/red]")
|
|
337
|
-
raise typer.Exit(1)
|
|
338
|
-
|
|
339
|
-
console.print(f"Loaded {len(result.records)} records from {dataset}")
|
|
340
|
-
console.print("Running workflow...")
|
|
341
|
-
|
|
342
|
-
try:
|
|
343
|
-
workflow_result = run_workflow(config, initial_prompt, result.records)
|
|
344
|
-
|
|
345
|
-
# Save result
|
|
346
|
-
output_file = save_workflow_result(workflow_result, output, config)
|
|
347
|
-
console.print(f"[green]Results saved to {output_file}[/green]")
|
|
348
|
-
|
|
349
|
-
# Print summary
|
|
350
|
-
console.print("\n[bold]Workflow Results:[/bold]")
|
|
351
|
-
console.print(f"Refined prompt length: {len(workflow_result.refined_prompt)} chars")
|
|
352
|
-
console.print(f"Outputs generated: {len(workflow_result.outputs)}")
|
|
353
|
-
|
|
354
|
-
eval_result = workflow_result.evaluation
|
|
355
|
-
console.print(f"\n[bold]Evaluation:[/bold]")
|
|
356
|
-
console.print(f"Total: {eval_result['total']}")
|
|
357
|
-
console.print(f"Correct: {eval_result['correct']}")
|
|
358
|
-
console.print(f"Incorrect: {eval_result['incorrect']}")
|
|
359
|
-
console.print(f"Accuracy: {eval_result['accuracy']:.1%}")
|
|
360
|
-
|
|
361
|
-
except Exception as e:
|
|
362
|
-
err_console.print(f"[red]Workflow error: {e}[/red]")
|
|
363
|
-
raise typer.Exit(1)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
@workflow_app.command("evaluate")
|
|
367
|
-
def workflow_evaluate(
|
|
368
|
-
results_file: Annotated[
|
|
369
|
-
Path,
|
|
370
|
-
typer.Argument(help="Path to workflow results JSON"),
|
|
88
|
+
typer.Argument(help="Target file or glob pattern to manage feedback for"),
|
|
371
89
|
],
|
|
372
|
-
output_json: Annotated[
|
|
373
|
-
bool,
|
|
374
|
-
typer.Option("--json", "-j", help="Output as JSON"),
|
|
375
|
-
] = False,
|
|
376
90
|
):
|
|
377
|
-
"""
|
|
378
|
-
if
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
console.print(f"Incorrect: {evaluation.get('incorrect', 0)}")
|
|
392
|
-
console.print(f"Accuracy: {evaluation.get('accuracy', 0):.1%}")
|
|
393
|
-
|
|
394
|
-
details = evaluation.get("details", [])
|
|
395
|
-
if details:
|
|
396
|
-
console.print("\n[bold]Details:[/bold]")
|
|
397
|
-
for d in details:
|
|
398
|
-
status = "[green]PASS[/green]" if d.get("match") else "[red]FAIL[/red]"
|
|
399
|
-
uri = d.get("uri") or f"record {d.get('record_idx')}"
|
|
400
|
-
console.print(f" {status} {uri}: expected={d.get('expected_label')}")
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
@workflow_app.command("prompt")
|
|
404
|
-
def workflow_prompt(
|
|
405
|
-
results_file: Annotated[
|
|
406
|
-
Path,
|
|
407
|
-
typer.Argument(help="Path to workflow results JSON"),
|
|
408
|
-
],
|
|
409
|
-
output: Annotated[
|
|
410
|
-
Optional[Path],
|
|
411
|
-
typer.Option("--output", "-o", help="Save prompt to file"),
|
|
412
|
-
] = None,
|
|
413
|
-
):
|
|
414
|
-
"""Extract the refined prompt from a workflow run."""
|
|
415
|
-
if not results_file.exists():
|
|
416
|
-
err_console.print(f"[red]File not found: {results_file}[/red]")
|
|
417
|
-
raise typer.Exit(1)
|
|
91
|
+
"""MarkBack: Create or view feedback for a target file."""
|
|
92
|
+
# Check if target is a glob pattern
|
|
93
|
+
if any(c in target for c in ('*', '?', '[')):
|
|
94
|
+
collect_glob(target)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
target = Path(target)
|
|
98
|
+
mb_path = get_mb_path(target)
|
|
99
|
+
|
|
100
|
+
if not mb_path.exists():
|
|
101
|
+
# Create new .mb file
|
|
102
|
+
if not target.exists():
|
|
103
|
+
err_console.print(f"[red]Target file not found: {target}[/red]")
|
|
104
|
+
raise typer.Exit(1)
|
|
418
105
|
|
|
419
|
-
|
|
420
|
-
|
|
106
|
+
from .types import Record
|
|
107
|
+
record = Record(
|
|
108
|
+
source=target,
|
|
109
|
+
feedback="",
|
|
110
|
+
)
|
|
111
|
+
write_file(mb_path, [record])
|
|
421
112
|
|
|
422
|
-
|
|
423
|
-
output.write_text(prompt, encoding="utf-8")
|
|
424
|
-
console.print(f"[green]Saved prompt to {output}[/green]")
|
|
425
|
-
else:
|
|
426
|
-
console.print(prompt)
|
|
113
|
+
open_editor(mb_path)
|
|
427
114
|
|
|
428
115
|
|
|
429
|
-
def
|
|
116
|
+
def cli():
|
|
430
117
|
"""Entry point for the CLI."""
|
|
431
|
-
|
|
118
|
+
typer.run(main)
|
|
432
119
|
|
|
433
120
|
|
|
434
121
|
if __name__ == "__main__":
|
|
435
|
-
|
|
122
|
+
cli()
|
markback/linter.py
CHANGED
|
@@ -137,36 +137,61 @@ def lint_prior_exists(
|
|
|
137
137
|
return diagnostics
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _is_position_invalid(source_ref) -> tuple[bool, str]:
|
|
141
|
+
"""Check if a SourceRef has an invalid position range.
|
|
142
|
+
|
|
143
|
+
Returns (is_invalid, error_message).
|
|
144
|
+
Position is invalid if:
|
|
145
|
+
- end_line < start_line
|
|
146
|
+
- end_line == start_line and end_column < start_column
|
|
147
|
+
"""
|
|
148
|
+
if source_ref.start_line is None or source_ref.end_line is None:
|
|
149
|
+
return False, ""
|
|
150
|
+
|
|
151
|
+
if source_ref.end_line < source_ref.start_line:
|
|
152
|
+
return True, f"end line {source_ref.end_line} is less than start line {source_ref.start_line}"
|
|
153
|
+
|
|
154
|
+
if source_ref.end_line == source_ref.start_line:
|
|
155
|
+
if (source_ref.start_column is not None and
|
|
156
|
+
source_ref.end_column is not None and
|
|
157
|
+
source_ref.end_column < source_ref.start_column):
|
|
158
|
+
return True, f"end column {source_ref.end_column} is less than start column {source_ref.start_column} on line {source_ref.start_line}"
|
|
159
|
+
|
|
160
|
+
return False, ""
|
|
161
|
+
|
|
162
|
+
|
|
140
163
|
def lint_line_range(
|
|
141
164
|
record: Record,
|
|
142
165
|
record_idx: int,
|
|
143
166
|
) -> list[Diagnostic]:
|
|
144
|
-
"""Check if line ranges are valid (end >= start)."""
|
|
167
|
+
"""Check if line/character ranges are valid (end position >= start position)."""
|
|
145
168
|
diagnostics: list[Diagnostic] = []
|
|
146
169
|
|
|
147
|
-
# Check @source
|
|
170
|
+
# Check @source range
|
|
148
171
|
if record.source and record.source.start_line is not None:
|
|
149
|
-
|
|
172
|
+
is_invalid, error_msg = _is_position_invalid(record.source)
|
|
173
|
+
if is_invalid:
|
|
150
174
|
diagnostics.append(Diagnostic(
|
|
151
175
|
file=record._source_file,
|
|
152
176
|
line=record._start_line,
|
|
153
177
|
column=None,
|
|
154
178
|
severity=Severity.ERROR,
|
|
155
179
|
code=ErrorCode.E011,
|
|
156
|
-
message=f"Invalid
|
|
180
|
+
message=f"Invalid range in @source: {error_msg}",
|
|
157
181
|
record_index=record_idx,
|
|
158
182
|
))
|
|
159
183
|
|
|
160
|
-
# Check @prior
|
|
184
|
+
# Check @prior range
|
|
161
185
|
if record.prior and record.prior.start_line is not None:
|
|
162
|
-
|
|
186
|
+
is_invalid, error_msg = _is_position_invalid(record.prior)
|
|
187
|
+
if is_invalid:
|
|
163
188
|
diagnostics.append(Diagnostic(
|
|
164
189
|
file=record._source_file,
|
|
165
190
|
line=record._start_line,
|
|
166
191
|
column=None,
|
|
167
192
|
severity=Severity.ERROR,
|
|
168
193
|
code=ErrorCode.E011,
|
|
169
|
-
message=f"Invalid
|
|
194
|
+
message=f"Invalid range in @prior: {error_msg}",
|
|
170
195
|
record_index=record_idx,
|
|
171
196
|
))
|
|
172
197
|
|
markback/parser.py
CHANGED
|
@@ -17,7 +17,7 @@ from .types import (
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
# Known header keywords
|
|
20
|
-
KNOWN_HEADERS = {"uri", "source", "prior"}
|
|
20
|
+
KNOWN_HEADERS = {"uri", "by", "source", "prior"}
|
|
21
21
|
|
|
22
22
|
# Patterns
|
|
23
23
|
HEADER_PATTERN = re.compile(r"^@([a-z]+)\s+(.+)$")
|
|
@@ -145,6 +145,7 @@ def parse_string(
|
|
|
145
145
|
nonlocal pending_uri, in_content, had_blank_line
|
|
146
146
|
|
|
147
147
|
uri = current_headers.get("uri") or pending_uri
|
|
148
|
+
by = current_headers.get("by")
|
|
148
149
|
source_str = current_headers.get("source")
|
|
149
150
|
source = SourceRef(source_str) if source_str else None
|
|
150
151
|
prior_str = current_headers.get("prior")
|
|
@@ -164,6 +165,7 @@ def parse_string(
|
|
|
164
165
|
record = Record(
|
|
165
166
|
feedback=feedback,
|
|
166
167
|
uri=uri,
|
|
168
|
+
by=by,
|
|
167
169
|
source=source,
|
|
168
170
|
prior=prior,
|
|
169
171
|
content=content,
|
|
@@ -242,14 +244,16 @@ def parse_string(
|
|
|
242
244
|
line_num,
|
|
243
245
|
)
|
|
244
246
|
|
|
245
|
-
# Use any pending @uri from previous line and @prior if present
|
|
247
|
+
# Use any pending @uri from previous line and @by, @prior if present
|
|
246
248
|
uri = pending_uri or current_headers.get("uri")
|
|
249
|
+
by = current_headers.get("by")
|
|
247
250
|
prior_str = current_headers.get("prior")
|
|
248
251
|
prior = SourceRef(prior_str) if prior_str else None
|
|
249
252
|
|
|
250
253
|
record = Record(
|
|
251
254
|
feedback=feedback or "",
|
|
252
255
|
uri=uri,
|
|
256
|
+
by=by,
|
|
253
257
|
source=source,
|
|
254
258
|
prior=prior,
|
|
255
259
|
content=None,
|
markback/types.py
CHANGED
|
@@ -78,8 +78,9 @@ class Diagnostic:
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
# Regex to parse line range from a path
|
|
82
|
-
|
|
81
|
+
# Regex to parse line/character range from a path
|
|
82
|
+
# Supports: path:line, path:line:col, path:line-line, path:line:col-line:col
|
|
83
|
+
_LINE_RANGE_PATTERN = re.compile(r'^(.+?):(\d+)(?::(\d+))?(?:-(\d+)(?::(\d+))?)?$')
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
@dataclass
|
|
@@ -89,6 +90,8 @@ class SourceRef:
|
|
|
89
90
|
is_uri: bool = False
|
|
90
91
|
start_line: Optional[int] = None
|
|
91
92
|
end_line: Optional[int] = None
|
|
93
|
+
start_column: Optional[int] = None
|
|
94
|
+
end_column: Optional[int] = None
|
|
92
95
|
_path_only: str = ""
|
|
93
96
|
|
|
94
97
|
def __post_init__(self):
|
|
@@ -102,16 +105,21 @@ class SourceRef:
|
|
|
102
105
|
self.is_uri = bool(parsed.scheme) and len(parsed.scheme) > 1
|
|
103
106
|
|
|
104
107
|
def _parse_line_range(self):
|
|
105
|
-
"""Parse optional line range from value."""
|
|
108
|
+
"""Parse optional line/character range from value."""
|
|
106
109
|
match = _LINE_RANGE_PATTERN.match(self.value)
|
|
107
110
|
if match:
|
|
108
111
|
self._path_only = match.group(1)
|
|
109
112
|
self.start_line = int(match.group(2))
|
|
110
113
|
if match.group(3):
|
|
111
|
-
self.
|
|
114
|
+
self.start_column = int(match.group(3))
|
|
115
|
+
if match.group(4):
|
|
116
|
+
self.end_line = int(match.group(4))
|
|
117
|
+
if match.group(5):
|
|
118
|
+
self.end_column = int(match.group(5))
|
|
112
119
|
else:
|
|
113
|
-
# Single line reference: start and end are the same
|
|
120
|
+
# Single line/position reference: start and end are the same
|
|
114
121
|
self.end_line = self.start_line
|
|
122
|
+
self.end_column = self.start_column
|
|
115
123
|
else:
|
|
116
124
|
self._path_only = self.value
|
|
117
125
|
|
|
@@ -122,12 +130,27 @@ class SourceRef:
|
|
|
122
130
|
|
|
123
131
|
@property
|
|
124
132
|
def line_range_str(self) -> Optional[str]:
|
|
125
|
-
"""Return formatted line range string, or None if no range."""
|
|
133
|
+
"""Return formatted line/character range string, or None if no range."""
|
|
126
134
|
if self.start_line is None:
|
|
127
135
|
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
|
|
137
|
+
# Build start position
|
|
138
|
+
if self.start_column is not None:
|
|
139
|
+
start = f":{self.start_line}:{self.start_column}"
|
|
140
|
+
else:
|
|
141
|
+
start = f":{self.start_line}"
|
|
142
|
+
|
|
143
|
+
# Check if end is the same as start (single position)
|
|
144
|
+
if self.start_line == self.end_line and self.start_column == self.end_column:
|
|
145
|
+
return start
|
|
146
|
+
|
|
147
|
+
# Build end position
|
|
148
|
+
if self.end_column is not None:
|
|
149
|
+
end = f"-{self.end_line}:{self.end_column}"
|
|
150
|
+
else:
|
|
151
|
+
end = f"-{self.end_line}"
|
|
152
|
+
|
|
153
|
+
return f"{start}{end}"
|
|
131
154
|
|
|
132
155
|
def resolve(self, base_path: Optional[Path] = None) -> Path:
|
|
133
156
|
"""Resolve to a file path (relative paths resolved against base_path)."""
|
|
@@ -162,6 +185,7 @@ class Record:
|
|
|
162
185
|
"""A MarkBack record containing content and feedback."""
|
|
163
186
|
feedback: str
|
|
164
187
|
uri: Optional[str] = None
|
|
188
|
+
by: Optional[str] = None
|
|
165
189
|
source: Optional[SourceRef] = None
|
|
166
190
|
prior: Optional[SourceRef] = None
|
|
167
191
|
content: Optional[str] = None
|
|
@@ -195,6 +219,7 @@ class Record:
|
|
|
195
219
|
"""Convert to JSON-serializable dict."""
|
|
196
220
|
return {
|
|
197
221
|
"uri": self.uri,
|
|
222
|
+
"by": self.by,
|
|
198
223
|
"source": str(self.source) if self.source else None,
|
|
199
224
|
"prior": str(self.prior) if self.prior else None,
|
|
200
225
|
"content": self.content,
|
markback/writer.py
CHANGED
|
@@ -38,17 +38,21 @@ def write_record_canonical(
|
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
if use_compact:
|
|
41
|
-
# Compact format: @uri on
|
|
41
|
+
# Compact format: @uri, @by, @prior on own lines (if present), then @source ... <<<
|
|
42
42
|
if record.uri:
|
|
43
43
|
lines.append(f"@uri {record.uri}")
|
|
44
|
+
if record.by:
|
|
45
|
+
lines.append(f"@by {record.by}")
|
|
44
46
|
if record.prior:
|
|
45
47
|
lines.append(f"@prior {record.prior}")
|
|
46
48
|
lines.append(f"@source {record.source} <<< {record.feedback}")
|
|
47
49
|
else:
|
|
48
50
|
# Full format
|
|
49
|
-
# Headers: @uri first, then @prior, then @source
|
|
51
|
+
# Headers: @uri first, then @by, then @prior, then @source
|
|
50
52
|
if record.uri:
|
|
51
53
|
lines.append(f"@uri {record.uri}")
|
|
54
|
+
if record.by:
|
|
55
|
+
lines.append(f"@by {record.by}")
|
|
52
56
|
if record.prior:
|
|
53
57
|
lines.append(f"@prior {record.prior}")
|
|
54
58
|
if record.source:
|
|
@@ -151,7 +155,10 @@ def write_label_file(record: Record) -> str:
|
|
|
151
155
|
|
|
152
156
|
if record.uri:
|
|
153
157
|
lines.append(f"@uri {record.uri}")
|
|
154
|
-
|
|
158
|
+
|
|
159
|
+
if record.by:
|
|
160
|
+
lines.append(f"@by {record.by}")
|
|
161
|
+
|
|
155
162
|
if record.prior:
|
|
156
163
|
lines.append(f"@prior {record.prior}")
|
|
157
164
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markback
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A compact, human-writable format for storing content paired with feedback/labels
|
|
5
5
|
Project-URL: Homepage, https://github.com/dandriscoll/markback
|
|
6
6
|
Project-URL: Repository, https://github.com/dandriscoll/markback
|
|
@@ -95,10 +95,17 @@ if result.has_errors:
|
|
|
95
95
|
|
|
96
96
|
## CLI Usage
|
|
97
97
|
|
|
98
|
+
The CLI is available via two commands:
|
|
99
|
+
- `markback` - Full command name
|
|
100
|
+
- `mb` - Convenient shorthand (works on all platforms including Windows)
|
|
101
|
+
|
|
102
|
+
Both commands are functionally identical. Examples below use `markback`, but you can substitute `mb` anywhere.
|
|
103
|
+
|
|
98
104
|
### Initialize configuration
|
|
99
105
|
|
|
100
106
|
```bash
|
|
101
107
|
markback init
|
|
108
|
+
# or: mb init
|
|
102
109
|
```
|
|
103
110
|
|
|
104
111
|
Creates a `.env` file with all configuration options.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
markback/__init__.py,sha256=B0-2dpUu5nkbnUI0hPz-x7PHiOl7M-tiRi6s3UCYJFk,1540
|
|
2
|
+
markback/cli.py,sha256=DkqY5WlpIhbAJ9lp6Hja2ie9qzMumVqIWpALnkKzzXQ,3447
|
|
3
|
+
markback/config.py,sha256=eTVhb7UwDER9FRYo8QUAvneLHSqXD2ZtLUgtBtnljUs,5455
|
|
4
|
+
markback/linter.py,sha256=vKy5JHTEGspF4-lWRL9o4zDWieghqQWkah-bP_n8-kM,11977
|
|
5
|
+
markback/llm.py,sha256=ON5_2C6v4KIk7_aIceulfWjEEI6hmallaPlLv-1-s_o,4692
|
|
6
|
+
markback/parser.py,sha256=KHC1QKmN1wSnYgcobC36zXUqL6cdNsZajC6fHlhETZc,19016
|
|
7
|
+
markback/types.py,sha256=t6HdFIWgBTlCfC2KRKKhSO4VBzfxZcP3Uq_zoh-bXZ4,11006
|
|
8
|
+
markback/workflow.py,sha256=zC1RUm1i1wgiciFDqUilJKJ0-bgInvctxhQ0h5WSdoQ,10485
|
|
9
|
+
markback/writer.py,sha256=HY_RYnEk27cIsodo9gGf_MvUb_Bvdmkz05S9kyq6Tdo,8093
|
|
10
|
+
markback-0.1.5.dist-info/METADATA,sha256=dHKSIe4l5IxixOSII6s05lQbWGtL7OPwqyLR-VrhSgk,5405
|
|
11
|
+
markback-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
markback-0.1.5.dist-info/entry_points.txt,sha256=o2T8WbdHx1yJPFm9nom7mAqKpldkHygGMGixZwm-1nc,68
|
|
13
|
+
markback-0.1.5.dist-info/licenses/LICENSE,sha256=lLK1n13C_CXb0M10O-6itEIDY6dsXKutZYQH-09n6s0,1068
|
|
14
|
+
markback-0.1.5.dist-info/RECORD,,
|
markback-0.1.2.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
markback/__init__.py,sha256=B0-2dpUu5nkbnUI0hPz-x7PHiOl7M-tiRi6s3UCYJFk,1540
|
|
2
|
-
markback/cli.py,sha256=5wMk1OUG7W_voS9DxeFxRJrBTMabEdOK_s_o3Irxuu0,13639
|
|
3
|
-
markback/config.py,sha256=eTVhb7UwDER9FRYo8QUAvneLHSqXD2ZtLUgtBtnljUs,5455
|
|
4
|
-
markback/linter.py,sha256=IctgPEFNfGDo5DcELdo0Ni3d7Dp0bIWtlDw61ccWDOQ,11210
|
|
5
|
-
markback/llm.py,sha256=ON5_2C6v4KIk7_aIceulfWjEEI6hmallaPlLv-1-s_o,4692
|
|
6
|
-
markback/parser.py,sha256=P7GRjlwhy8j6Tnub7XAqILtZ4pFdkfdHhB-aIjLVRYU,18881
|
|
7
|
-
markback/types.py,sha256=tFunBAoqUEVf9mi_4N1QwWmOsKt0nocZK7M7K_rybWg,10097
|
|
8
|
-
markback/workflow.py,sha256=zC1RUm1i1wgiciFDqUilJKJ0-bgInvctxhQ0h5WSdoQ,10485
|
|
9
|
-
markback/writer.py,sha256=3-LeupyuruGv4WZH9pV65hU4YDKuC5HgIIZ8YZ2SZnM,7896
|
|
10
|
-
markback-0.1.2.dist-info/METADATA,sha256=d_xMmpicyEYeakJ4hA8SsrlGgj5Qxpd6fMv7tKf-eaI,5133
|
|
11
|
-
markback-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
-
markback-0.1.2.dist-info/entry_points.txt,sha256=Bc9aXvtlPxVPuOJ9BWGngAVrkx5dMvRgujjVzXC-V5U,46
|
|
13
|
-
markback-0.1.2.dist-info/licenses/LICENSE,sha256=lLK1n13C_CXb0M10O-6itEIDY6dsXKutZYQH-09n6s0,1068
|
|
14
|
-
markback-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|