askround 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.
askround/cli.py ADDED
@@ -0,0 +1,1179 @@
1
+ """The `askround` command.
2
+
3
+ askround ask "<question>" [--provider NAME ...] [--model PROVIDER=MODEL ...]
4
+ [--runs N] [--product NAME[,ALIAS...]] [--locale COUNTRY[:LANG] ...]
5
+ [--no-search] [--out DIR] [--json]
6
+ askround run PRODUCT.toml [--provider NAME ...] [--runs N] [--extract] [--out DIR]
7
+ askround report [ROUND] [--product NAME[,ALIAS...] ...] [--out DIR] [--json]
8
+ askround doctor [--provider NAME ...] [--model PROVIDER=MODEL ...] [--no-check] [--json]
9
+
10
+ `ask` sends the question to every provider whose API key is in the environment (or the
11
+ ones named with --provider), --runs times each (default 3), with web search on unless
12
+ --no-search, prints each answer under its provider's heading with the sources it
13
+ cited, then for each --product the share of runs that named it and its positions, and
14
+ saves the round under --out (default `.askround/rounds`, or `ASKROUND_DIR`).
15
+
16
+ `report` reads a stored round back (by default the latest) and sums it up in one short
17
+ report, Markdown or --json, without the answers' text.
18
+
19
+ `doctor` says which providers can run here: key variable (never the value), model,
20
+ search tool and a free check call. It exits 2 when no provider can run.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import dataclasses
27
+ import json
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ from askround import (
33
+ __version__,
34
+ budget,
35
+ citations,
36
+ costs,
37
+ diff,
38
+ discover,
39
+ export,
40
+ gaps,
41
+ jobs,
42
+ leading,
43
+ links,
44
+ lookup,
45
+ private,
46
+ product,
47
+ refusals,
48
+ research,
49
+ reuse,
50
+ series,
51
+ sets,
52
+ wiring,
53
+ )
54
+ from askround import report as report_
55
+ from askround.claims import product_claims
56
+ from askround.doctor import Status, diagnose
57
+ from askround.extract import choose, extract_round
58
+ from askround.provenance import schema
59
+ from askround.providers import Answer, Locale, all_providers, by_name
60
+ from askround.rivals import key as rival_key
61
+ from askround.rivals import rivals
62
+ from askround.round import (
63
+ DEFAULT_RUNS,
64
+ Round,
65
+ cost,
66
+ failures,
67
+ frequency,
68
+ run_round,
69
+ save_round,
70
+ summary,
71
+ )
72
+
73
+
74
+ def build_parser() -> argparse.ArgumentParser:
75
+ parser = argparse.ArgumentParser(
76
+ prog="askround", description="Ask several AI assistants the same question."
77
+ )
78
+ parser.add_argument("--version", action="version", version=f"askround {__version__}")
79
+ paid = argparse.ArgumentParser(add_help=False)
80
+ paid.add_argument(
81
+ "--max-cost",
82
+ type=float,
83
+ metavar="USD",
84
+ help="refuse, before anything is sent, a run estimated above this (or ASKROUND_MAX_COST)",
85
+ )
86
+ paid.add_argument(
87
+ "--approve",
88
+ action="store_true",
89
+ help="the person approves this run past the day or month budget (agents cannot)",
90
+ )
91
+ paid.add_argument(
92
+ "--fresh",
93
+ action="store_true",
94
+ help="ask again even if the same round was asked recently (it counts against the budget)",
95
+ )
96
+ paid.add_argument(
97
+ "--estimate-only",
98
+ action="store_true",
99
+ help="run every check and print the estimate; ask nothing",
100
+ )
101
+ # where a job (jobs.py) records each answer as it lands; set by the MCP server
102
+ paid.add_argument("--job", type=Path, help=argparse.SUPPRESS)
103
+ common = argparse.ArgumentParser(add_help=False)
104
+ common.add_argument(
105
+ "--provider",
106
+ action="append",
107
+ dest="providers",
108
+ metavar="NAME",
109
+ help="only this provider (repeatable)",
110
+ )
111
+ common.add_argument(
112
+ "--model",
113
+ action="append",
114
+ dest="models",
115
+ type=_pin,
116
+ metavar="PROVIDER=MODEL",
117
+ help="pin the model one provider uses (repeatable)",
118
+ )
119
+ commands = parser.add_subparsers(dest="command", required=True)
120
+ ask = commands.add_parser(
121
+ "ask", parents=[common, paid], help="send one question to every configured provider"
122
+ )
123
+ ask.add_argument("question")
124
+ ask.add_argument(
125
+ "--no-search",
126
+ dest="search",
127
+ action="store_false",
128
+ help="ask without web search, to compare with a searched round",
129
+ )
130
+ ask.add_argument(
131
+ "--runs",
132
+ type=_positive,
133
+ default=DEFAULT_RUNS,
134
+ help=f"times each provider is asked, in fresh conversations (default {DEFAULT_RUNS})",
135
+ )
136
+ ask.add_argument(
137
+ "--product",
138
+ action="append",
139
+ dest="products",
140
+ type=_names,
141
+ metavar="NAME[,ALIAS...]",
142
+ help="report how often this product is named and where (repeatable)",
143
+ )
144
+ ask.add_argument(
145
+ "--locale",
146
+ action="append",
147
+ dest="locales",
148
+ type=_locale,
149
+ metavar="COUNTRY[:LANGUAGE]",
150
+ help="ask as someone in this country, e.g. BR:pt-BR; repeat to compare (each "
151
+ "locale is a full set of runs)",
152
+ )
153
+ ask.add_argument(
154
+ "--extract",
155
+ nargs="?",
156
+ const="auto",
157
+ metavar="PROVIDER",
158
+ help="read each answer's recommendations in order with a model (one extra call "
159
+ "per answer), so a product's rank counts tables and prose; default extractor: the "
160
+ "first of openai, gemini, anthropic with a key",
161
+ )
162
+ ask.add_argument(
163
+ "--check-rivals",
164
+ nargs="?",
165
+ type=_positive,
166
+ const=10,
167
+ metavar="N",
168
+ help="look up whether the top N rivals (default 10) exist: GitHub, then one "
169
+ "searched call each; needs --extract",
170
+ )
171
+ ask.add_argument(
172
+ "--product-file",
173
+ action="append",
174
+ dest="product_files",
175
+ type=Path,
176
+ metavar="PATH",
177
+ help="a product file (TOML: names, places, facts); with --extract, the answers' "
178
+ "claims are compared with its facts (repeatable)",
179
+ )
180
+ ask.add_argument(
181
+ "--brand",
182
+ action="store_true",
183
+ help="the question names the product on purpose; without this it is refused",
184
+ )
185
+ ask.add_argument(
186
+ "--check-links",
187
+ action="store_true",
188
+ help="follow the links each answer gives beside a --product, and flag the ones "
189
+ "that land on someone else's page",
190
+ )
191
+ ask.add_argument("--out", type=Path, help="where rounds are saved")
192
+ ask.add_argument("--json", action="store_true", help="print the round as JSON")
193
+ run = commands.add_parser(
194
+ "run", parents=[common, paid], help="ask every question of a product file (a prompt set)"
195
+ )
196
+ run.add_argument("file", type=Path, metavar="PRODUCT.toml")
197
+ run.add_argument(
198
+ "--runs", type=_positive, help="runs per question, over what the file sets for each"
199
+ )
200
+ run.add_argument("--no-search", dest="search", action="store_false", help="no web search")
201
+ run.add_argument(
202
+ "--extract",
203
+ nargs="?",
204
+ const="auto",
205
+ metavar="PROVIDER",
206
+ help="read recommendations and claims, and compare the claims with the file's facts",
207
+ )
208
+ run.add_argument("--check-links", action="store_true", help="follow the product's links")
209
+ run.add_argument("--out", type=Path, help="where rounds are saved")
210
+ run.add_argument("--json", action="store_true", help="print the set's report as JSON")
211
+ disc = commands.add_parser(
212
+ "discover",
213
+ parents=[common, paid],
214
+ help="find which way of asking leads the assistants to a product",
215
+ )
216
+ disc.add_argument("file", type=Path, metavar="PRODUCT.toml")
217
+ disc.add_argument("--seed", help="the question to vary (default: the file's first one)")
218
+ disc.add_argument("--variants", type=_positive, default=6, help="how many (default 6)")
219
+ disc.add_argument("--runs", type=_positive, default=1, help="runs per question (default 1)")
220
+ disc.add_argument(
221
+ "--extract",
222
+ nargs="?",
223
+ const="auto",
224
+ metavar="PROVIDER",
225
+ help="also rank by the extraction pass (one more call per answer)",
226
+ )
227
+ disc.add_argument("--out", type=Path, help="where rounds are saved")
228
+ disc.add_argument("--json", action="store_true", help="print the ranking as JSON")
229
+ find = commands.add_parser(
230
+ "lookup", help="check whether products exist: GitHub, then one searched call each"
231
+ )
232
+ find.add_argument("names", nargs="+", metavar="NAME")
233
+ find.add_argument("--context", default="", help="what they were recommended for")
234
+ find.add_argument("--no-web", dest="web", action="store_false", help="GitHub only, free")
235
+ find.add_argument("--json", action="store_true", help="print the verdicts as JSON")
236
+ dif = commands.add_parser("diff", help="what changed between two rounds or prompt set runs")
237
+ dif.add_argument("old", nargs="?", type=Path, help="the earlier round or set (directory)")
238
+ dif.add_argument("new", nargs="?", type=Path, help="the later one")
239
+ dif.add_argument("--product", type=_names, metavar="NAME[,ALIAS...]", help="compare for it")
240
+ dif.add_argument("--out", type=Path, help="where rounds are saved")
241
+ dif.add_argument("--json", action="store_true", help="print the changes as JSON")
242
+ serie = commands.add_parser(
243
+ "series", help="a product's share of answers over time, from every round kept"
244
+ )
245
+ serie.add_argument(
246
+ "--product", dest="product", metavar="NAME", help="the product to follow (required)"
247
+ )
248
+ serie.add_argument("--by", choices=series.PERIODS, default="week", help="default: week")
249
+ serie.add_argument("--question", help="only questions containing this text")
250
+ serie.add_argument(
251
+ "--rebuild", action="store_true", help="write the series again from every round.json"
252
+ )
253
+ serie.add_argument("--out", type=Path, help="where rounds are saved")
254
+ serie.add_argument("--json", action="store_true", help="print the table as JSON")
255
+ research_ = commands.add_parser(
256
+ "research",
257
+ parents=[common, paid],
258
+ help="ask a research question everywhere and merge the answers' claims and sources",
259
+ )
260
+ research_.add_argument("question")
261
+ research_.add_argument(
262
+ "--runs", type=_positive, default=1, help="runs per provider (default 1)"
263
+ )
264
+ research_.add_argument(
265
+ "--extract", metavar="PROVIDER", default="auto", help="who reads and merges (default: auto)"
266
+ )
267
+ research_.add_argument("--out", type=Path, help="where rounds are saved")
268
+ research_.add_argument("--json", action="store_true", help="print the merged claims as JSON")
269
+ spend = commands.add_parser("budget", help="what has been spent against the budget")
270
+ spend.add_argument("--out", type=Path, help="where rounds are saved")
271
+ spend.add_argument("--json", action="store_true", help="print it as JSON")
272
+ why = commands.add_parser("explain", help="what a refusal code means and how to pass it")
273
+ why.add_argument("code", nargs="?", help="e.g. question.leading (default: list them)")
274
+ serve = commands.add_parser("mcp", help="serve askround to an agent as an MCP server (stdio)")
275
+ serve.add_argument("--out", type=Path, help="where rounds are saved")
276
+ wire = commands.add_parser(
277
+ "install", help="wire a project to the askround plugin in its .claude/settings.json"
278
+ )
279
+ wire.add_argument(
280
+ "project", nargs="?", type=Path, default=Path("."), help="the project (default: here)"
281
+ )
282
+ wire.add_argument(
283
+ "--check", action="store_true", help="write nothing; exit 1 when it is not wired"
284
+ )
285
+ wire.add_argument(
286
+ "--vendor",
287
+ action="store_true",
288
+ help="also commit a copy of askround and its .mcp.json entry, for Claude Code on the web",
289
+ )
290
+ exp = commands.add_parser("export", help="a product's numbers from the series, for a dashboard")
291
+ exp.add_argument("--product", required=True, metavar="NAME", help="the product to export")
292
+ exp.add_argument("--from", dest="since", metavar="YYYY-MM-DD", default="", help="first day")
293
+ exp.add_argument("--to", dest="until", metavar="YYYY-MM-DD", default="", help="last day")
294
+ exp.add_argument("--format", choices=("csv", "json", "daily"), default="csv")
295
+ exp.add_argument("--key", help="the product's key in --format daily (default: its name)")
296
+ exp.add_argument("--file", type=Path, help="write here instead of printing")
297
+ exp.add_argument("--out", type=Path, help="where rounds are saved")
298
+ report = commands.add_parser(
299
+ "report", help="sum a stored round up: Markdown for a person, JSON for a pipeline"
300
+ )
301
+ report.add_argument(
302
+ "round",
303
+ nargs="?",
304
+ type=Path,
305
+ help="a round's directory or round.json (default: the latest round under --out)",
306
+ )
307
+ report.add_argument(
308
+ "--product",
309
+ action="append",
310
+ dest="products",
311
+ type=_names,
312
+ metavar="NAME[,ALIAS...]",
313
+ help="report on this product (default: the products the round was asked about)",
314
+ )
315
+ report.add_argument("--out", type=Path, help="where rounds are saved")
316
+ report.add_argument("--json", action="store_true", help="print the report as JSON")
317
+ report.add_argument(
318
+ "--brief",
319
+ action="store_true",
320
+ help="the round in a few hundred tokens, each fact with its answers' ids (JSON)",
321
+ )
322
+ doctor = commands.add_parser(
323
+ "doctor", parents=[common], help="say which providers can run here"
324
+ )
325
+ doctor.add_argument(
326
+ "--no-check", dest="check", action="store_false", help="make no network call"
327
+ )
328
+ doctor.add_argument("--json", action="store_true", help="print the report as JSON")
329
+ return parser
330
+
331
+
332
+ def _positive(value: str) -> int:
333
+ number = int(value)
334
+ if number < 1:
335
+ raise argparse.ArgumentTypeError("must be at least 1")
336
+ return number
337
+
338
+
339
+ def _pin(value: str) -> tuple[str, str]:
340
+ provider, sep, model = value.partition("=")
341
+ if not sep or not provider.strip() or not model.strip():
342
+ raise argparse.ArgumentTypeError("expected PROVIDER=MODEL, e.g. openai=gpt-5")
343
+ return provider.strip(), model.strip()
344
+
345
+
346
+ def _locale(value: str) -> Locale:
347
+ try:
348
+ return Locale.parse(value)
349
+ except ValueError as error:
350
+ raise argparse.ArgumentTypeError(str(error)) from None
351
+
352
+
353
+ def _names(value: str) -> list[str]:
354
+ names = [n.strip() for n in value.split(",") if n.strip()]
355
+ if not names:
356
+ raise argparse.ArgumentTypeError("a product needs a name")
357
+ return names
358
+
359
+
360
+ def main(argv: list[str] | None = None, post=None, fetch=None) -> int:
361
+ args = build_parser().parse_args(argv)
362
+ # the commands that ask no provider
363
+ local = {
364
+ "report": lambda: run_report(args),
365
+ "explain": lambda: run_explain(args),
366
+ "lookup": lambda: run_lookup(args, post),
367
+ "series": lambda: run_series(args),
368
+ "diff": lambda: run_diff(args),
369
+ "export": lambda: run_export(args),
370
+ "budget": lambda: run_budget(args),
371
+ "mcp": lambda: run_mcp(args),
372
+ "install": lambda: run_install(args),
373
+ }
374
+ if args.command in local:
375
+ return local[args.command]()
376
+ kwargs = {"post": post} if post is not None else {}
377
+ kwargs["models"] = dict(args.models or [])
378
+ try:
379
+ providers = by_name(args.providers, **kwargs) if args.providers else all_providers(**kwargs)
380
+ except ValueError as error:
381
+ print(f"askround: {error}", file=sys.stderr)
382
+ return REFUSED
383
+ paid = {
384
+ "doctor": lambda: run_doctor(providers, args.check, args.json),
385
+ "run": lambda: run_prompt_set(args, providers, post, fetch),
386
+ "discover": lambda: run_discover(args, providers, post),
387
+ "research": lambda: run_research(args, providers, post),
388
+ "ask": lambda: run_ask(args, providers, post, fetch),
389
+ }
390
+ code = paid[args.command]()
391
+ if getattr(args, "job", None):
392
+ jobs.finish(args.job, code)
393
+ return code
394
+
395
+
396
+ def _estimate_only(args) -> int:
397
+ """After every check passed: the estimate, and nothing asked."""
398
+ print(json.dumps({"schema": schema("estimate"), "estimate": args.estimate}, indent=2))
399
+ return OK
400
+
401
+
402
+ def _recorder(args):
403
+ return jobs.Recorder(args.job) if getattr(args, "job", None) else None
404
+
405
+
406
+ def run_explain(args) -> int:
407
+ if not args.code:
408
+ for code, (meaning, _) in sorted(refusals.CODES.items()):
409
+ print(f"{code:28} {meaning}")
410
+ return OK
411
+ print(refusals.explain(args.code))
412
+ return OK if args.code in refusals.CODES else REFUSED
413
+
414
+
415
+ def run_mcp(args) -> int:
416
+ from askround import mcp
417
+
418
+ mcp.serve(out=str(args.out) if args.out else None)
419
+ return OK
420
+
421
+
422
+ def run_install(args) -> int:
423
+ """Write the marketplace and the plugin into the project's settings, or check them."""
424
+ steps = [wiring.install] + ([wiring.vendor] if args.vendor else [])
425
+ try:
426
+ done = [step(args.project, check=args.check) for step in steps]
427
+ except (ValueError, OSError) as error:
428
+ print(f"askround: {error}", file=sys.stderr)
429
+ return REFUSED
430
+ gaps = [gap for _, found in done for gap in found]
431
+ if args.check and gaps:
432
+ print("not wired:", file=sys.stderr)
433
+ for gap in gaps:
434
+ print(f" {gap}", file=sys.stderr)
435
+ command = "askround install --vendor" if args.vendor else "askround install"
436
+ print(f" {command} writes them", file=sys.stderr)
437
+ return FAILED
438
+ for path, found in done:
439
+ print(f"{'wrote' if found and not args.check else 'in step'}: {path}")
440
+ if gaps:
441
+ print("commit them, and every clone gets askround")
442
+ return OK
443
+
444
+
445
+ def run_budget(args) -> int:
446
+ """What has been spent today and this month, against the budget, and where it is set."""
447
+ state = budget.status(_rounds_dir(args.out))
448
+ if args.json:
449
+ print(json.dumps({"schema": schema("budget"), **state}, indent=2))
450
+ return OK
451
+ limits = state["budget"]
452
+ day = f"${limits['day']:.2f}" if limits["day"] is not None else "none"
453
+ month = f"${limits['month']:.2f}" if limits["month"] is not None else "none"
454
+ print(
455
+ f"spent today ${state['today']:.4f} of {day}; this month ${state['month']:.4f} of {month}"
456
+ )
457
+ print(f"budget set in {state['config']}")
458
+ return OK
459
+
460
+
461
+ def _budget(checks, est, args) -> None:
462
+ """Print the estimate; a run above the cap, or past the person's day or month budget
463
+ without their approval, is a problem to refuse (law L5)."""
464
+ print(f"askround: {costs.describe(est)}", file=sys.stderr)
465
+ args.estimate = costs.as_dict(est)
466
+ limit = costs.cap(args.max_cost)
467
+ if limit is not None and est.total > limit:
468
+ checks.add("cost.over-cap", f"the estimate ${est.total:.4f} is above the cap ${limit:.4f}")
469
+ passing = budget.over(_rounds_dir(args.out), est.total)
470
+ if passing and not getattr(args, "approve", False):
471
+ checks.add("budget.over", f"{passing}; this run needs the person's approval")
472
+
473
+
474
+ def run_research(args, providers, post) -> int:
475
+ checks = refusals.Checks()
476
+ providers = _usable(checks, providers)
477
+ _text(checks, args.question, "the question")
478
+ reader = _reader(checks, args.extract, post)
479
+ if providers:
480
+ _budget(checks, costs.estimate(providers, args.runs, True, reader), args)
481
+ try:
482
+ _refuse(checks)
483
+ except Refused as refusal:
484
+ print(f"askround: {refusal}", file=sys.stderr)
485
+ return REFUSED
486
+ if args.estimate_only:
487
+ return _estimate_only(args)
488
+ round_ = run_round(args.question, providers, runs=args.runs, on_answer=_recorder(args))
489
+ before = reader.spent
490
+ found = research.merge(args.question, research.read(round_, reader), reader)
491
+ round_.extraction_cost = reader.spent - before
492
+ directory = save_round(round_, _rounds_dir(args.out))
493
+ names = list(dict.fromkeys(r.provider for r in round_.results if r.answer))
494
+ text = research.markdown(args.question, found, names)
495
+ (directory / "research.md").write_text(text, encoding="utf-8")
496
+ data = {
497
+ "schema": schema("research"),
498
+ "question": args.question,
499
+ "providers": names,
500
+ **research.as_json(found),
501
+ }
502
+ (directory / "research.json").write_text(
503
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
504
+ )
505
+ if args.json:
506
+ print(json.dumps({**data, "saved": str(directory)}, ensure_ascii=False, indent=2))
507
+ else:
508
+ print(text, end="")
509
+ print(f"\n{_cost_line(cost(round_))}\nsaved: {directory}")
510
+ return 0
511
+
512
+
513
+ def _discover_preflight(args, providers, post):
514
+ checks = refusals.Checks()
515
+ providers = _usable(checks, providers)
516
+ item = _load(checks, args.file)
517
+ writer = _reader(checks, args.extract or "auto", post)
518
+ seed = args.seed or (item.questions[0].text if item and item.questions else "")
519
+ if item and not seed:
520
+ checks.add("product-file.no-questions", f"{args.file} has no [[question]] and no --seed")
521
+ if item and seed:
522
+ _text(checks, seed, "the seed", [item.names], item.phrases)
523
+ if providers:
524
+ calls = args.runs * (args.variants + 1)
525
+ extractor = writer if args.extract else None
526
+ _budget(checks, costs.estimate(providers, calls, True, extractor), args)
527
+ _refuse(checks)
528
+ return providers, item, writer, seed
529
+
530
+
531
+ def run_discover(args, providers, post) -> int:
532
+ try:
533
+ providers, item, writer, seed = _discover_preflight(args, providers, post)
534
+ except Refused as refusal:
535
+ print(f"askround: {refusal}", file=sys.stderr)
536
+ return REFUSED
537
+ if args.estimate_only:
538
+ return _estimate_only(args)
539
+ variants, dropped = discover.generate(seed, item, writer, args.variants)
540
+ questions = [product.Question(seed, "seed"), *variants]
541
+ trial = dataclasses.replace(item, questions=questions)
542
+ directory, rounds = sets.run_set(
543
+ trial,
544
+ providers,
545
+ _rounds_dir(args.out),
546
+ runs=args.runs,
547
+ extractor=writer if args.extract else None,
548
+ source=str(args.file),
549
+ )
550
+ ranked = discover.rank(questions, rounds, item.names)
551
+ lines = [f"# Discovery: which question leads to {item.name}", ""]
552
+ lines += ["| rank | score | named | best | axis | question |", "|---|---|---|---|---|---|"]
553
+ for n, r in enumerate(ranked, 1):
554
+ q = r.question
555
+ where = f" ({q.locale})" if q.locale else ""
556
+ best = f"#{r.best}" if r.best else "-"
557
+ named = f"{r.named}/{r.answers}"
558
+ lines.append(f"| {n} | {r.score:.2f} | {named} | {best} | {q.purpose} | {q.text}{where} |")
559
+ lines += [
560
+ "",
561
+ "score: 1/position where it is recommended, 1/2 where only named, over every answer.",
562
+ ]
563
+ if args.runs < 3:
564
+ lines += [
565
+ f"With {args.runs} run(s) per question this is a sketch: answers vary from run to "
566
+ "run. Add the promising questions to the product file and `askround run` them.",
567
+ ]
568
+ if dropped:
569
+ lines += [
570
+ "",
571
+ "Dropped for naming the product or carrying personal data: " + "; ".join(dropped),
572
+ ]
573
+ text = "\n".join(lines) + "\n"
574
+ (directory / "discovery.md").write_text(text, encoding="utf-8")
575
+ if args.json:
576
+ data = {
577
+ "schema": schema("discovery"),
578
+ "product": item.name,
579
+ "saved": str(directory),
580
+ "ranked": [
581
+ {
582
+ "question": r.question.text,
583
+ "axis": r.question.purpose,
584
+ "locale": str(r.question.locale) if r.question.locale else None,
585
+ "score": round(r.score, 4),
586
+ "named": r.named,
587
+ "answers": r.answers,
588
+ "best": r.best,
589
+ }
590
+ for r in ranked
591
+ ],
592
+ "dropped": dropped,
593
+ }
594
+ print(json.dumps(data, ensure_ascii=False, indent=2))
595
+ else:
596
+ print(text, end="")
597
+ print(f"\nsaved: {directory}")
598
+ return outcome([r for round_ in rounds for r in round_.results])
599
+
600
+
601
+ def _set_preflight(args, providers, post):
602
+ checks = refusals.Checks()
603
+ providers = _usable(checks, providers)
604
+ item = _load(checks, args.file)
605
+ if item and not item.questions:
606
+ checks.add("product-file.no-questions", f"{args.file} has no [[question]] to ask")
607
+ for n, q in enumerate(item.questions if item else [], start=1):
608
+ _text(checks, q.text, f"question {n}", [item.names], item.phrases, q.brand)
609
+ extractor = _reader(checks, args.extract, post) if args.extract else None
610
+ if providers and item:
611
+ est = sets.estimate(item, providers, args.runs, args.search, extractor)
612
+ _budget(checks, est, args)
613
+ _refuse(checks)
614
+ return providers, item, extractor
615
+
616
+
617
+ def run_prompt_set(args, providers, post, fetch=None) -> int:
618
+ try:
619
+ providers, item, extractor = _set_preflight(args, providers, post)
620
+ except Refused as refusal:
621
+ print(f"askround: {refusal}", file=sys.stderr)
622
+ return REFUSED
623
+ if args.estimate_only:
624
+ return _estimate_only(args)
625
+ get = (fetch or links.fetch) if args.check_links else None
626
+ directory, rounds = sets.run_set(
627
+ item,
628
+ providers,
629
+ _rounds_dir(args.out),
630
+ search=args.search,
631
+ runs=args.runs,
632
+ extractor=extractor,
633
+ get=get,
634
+ source=str(args.file),
635
+ on_answer=_recorder(args),
636
+ )
637
+ data = report_.build_set(directory / "set.json")
638
+ if args.json:
639
+ print(json.dumps({**data, "saved": str(directory)}, ensure_ascii=False, indent=2))
640
+ else:
641
+ print(report_.set_markdown(data), end="")
642
+ print(f"\nsaved: {directory}")
643
+ return outcome([r for round_ in rounds for r in round_.results])
644
+
645
+
646
+ class Refused(Exception):
647
+ """A run stopped before anything was paid for (law L2)."""
648
+
649
+
650
+ # the exit codes are the contract a script, a hook or a CI job reads
651
+ OK, FAILED, REFUSED, PARTIAL = 0, 1, 2, 3
652
+
653
+
654
+ def outcome(results) -> int:
655
+ """OK when every answer came back, PARTIAL when some failed, FAILED when none did."""
656
+ answered = sum(r.ok for r in results)
657
+ if answered == len(results):
658
+ return OK
659
+ return PARTIAL if answered else FAILED
660
+
661
+
662
+ def _refuse(checks: refusals.Checks) -> None:
663
+ if checks:
664
+ raise Refused(checks.report())
665
+
666
+
667
+ def _usable(checks: refusals.Checks, providers) -> list:
668
+ usable = [p for p in providers if p.configured()]
669
+ if not usable:
670
+ checks.add("provider.no-key", "no provider here has an API key in the environment")
671
+ return usable
672
+
673
+
674
+ def _text(checks, text: str, where: str, products=(), phrases=(), brand: bool = False) -> None:
675
+ """A question that is empty, too long, carries personal data, or names its product."""
676
+ if not text.strip():
677
+ checks.add("question.empty", f"{where} is empty")
678
+ return
679
+ if len(text) > refusals.QUESTION_LIMIT:
680
+ checks.add("question.too-long", f"{where} is {len(text)} characters")
681
+ found = private.scan(text)
682
+ if found:
683
+ checks.add("question.personal-data", f"{where} carries {', '.join(found)}: {text!r}")
684
+ if not brand:
685
+ hits = [t for names in products for t in leading.leads(text, names, phrases)]
686
+ if hits:
687
+ named = ", ".join(dict.fromkeys(hits))
688
+ checks.add(
689
+ "question.leading", f"{where} names the product it tests ({named}): {text!r}"
690
+ )
691
+
692
+
693
+ def _reader(checks, name: str, post):
694
+ try:
695
+ return choose(name, **_post(post))
696
+ except ValueError as error:
697
+ checks.add("extract.no-reader", str(error))
698
+ return None
699
+
700
+
701
+ def _load(checks, path: Path):
702
+ try:
703
+ return product.load(path)
704
+ except (OSError, ValueError) as error:
705
+ checks.add("product-file.invalid", str(error))
706
+ return None
707
+
708
+
709
+ def _preflight(args, providers, post):
710
+ """Everything that could refuse, decided before the round, every problem at once:
711
+ the providers that can run, the product files, the question, the extractor and the
712
+ budget."""
713
+ checks = refusals.Checks()
714
+ providers = _usable(checks, providers)
715
+ files = [f for f in (_load(checks, path) for path in args.product_files or []) if f]
716
+ products = [*(args.products or []), *(f.names for f in files)]
717
+ phrases = [p for f in files for p in f.phrases]
718
+ _text(checks, args.question, "the question", products, phrases, args.brand)
719
+ if args.check_rivals and not args.extract:
720
+ checks.add("extract.needed", "--check-rivals reads the rivals from --extract")
721
+ if args.check_links and not products:
722
+ checks.add("product.needed", "--check-links follows a product's links")
723
+ extractor = _reader(checks, args.extract, post) if args.extract else None
724
+ if providers:
725
+ calls = args.runs * max(1, len(args.locales or []))
726
+ est = costs.estimate(providers, calls, args.search, extractor)
727
+ _budget(checks, est, args)
728
+ args.estimate = costs.as_dict(est)
729
+ _refuse(checks)
730
+ return providers, files, products, extractor
731
+
732
+
733
+ def _after(args, round_, files, products, extractor, post, fetch) -> None:
734
+ """The passes over a round that already answered."""
735
+ if extractor is not None:
736
+ before = extractor.spent
737
+ extract_round(round_, extractor)
738
+ for f in files:
739
+ if f.facts:
740
+ round_.gaps[f.name] = gaps.compare(round_, f, extractor)
741
+ round_.extraction_cost = extractor.spent - before
742
+ if args.check_rivals:
743
+ top = rivals(round_, products).rivals[: args.check_rivals]
744
+ web = lookup.web_lookup(**_post(post))
745
+ verdicts = lookup.check([v.name for v in top], args.question, web=web, **_post(post))
746
+ round_.checks = {rival_key(v.name): v for v in verdicts}
747
+ if args.check_links:
748
+ for names in products:
749
+ round_.links[names[0]] = links.check_links(round_, names, **_fetch(fetch))
750
+
751
+
752
+ def _reused(args, providers) -> int | None:
753
+ """Answer from a recent round asked the same way, calling nothing; None when there
754
+ is none (or --fresh asks again)."""
755
+ if args.fresh or args.estimate_only or getattr(args, "job", None):
756
+ return None
757
+ usable = [p for p in providers if p.configured()]
758
+ if not usable:
759
+ return None
760
+ found = reuse.find(
761
+ _rounds_dir(args.out),
762
+ args.question,
763
+ usable,
764
+ args.runs,
765
+ args.search,
766
+ args.locales or [],
767
+ bool(args.extract),
768
+ )
769
+ if found is None:
770
+ return None
771
+ path, hours = found
772
+ round_, asked = report_.load_round(path)
773
+ files = [f for f in (_try_load(p) for p in args.product_files or []) if f]
774
+ products = [*(args.products or []), *(f.names for f in files)] or asked
775
+ note = {"from": round_.asked_at.isoformat(), "age_hours": hours, "round": str(path.parent)}
776
+ if args.json:
777
+ data = {**summary(round_, products), "saved": str(path.parent), "reused": note}
778
+ print(json.dumps(data, ensure_ascii=False, indent=2))
779
+ else:
780
+ print(
781
+ f"reused the round asked {hours:g} h ago ({round_.asked_at:%Y-%m-%d %H:%M} UTC), "
782
+ "at no cost; --fresh asks again\n"
783
+ )
784
+ print(render(round_, path.parent, products))
785
+ return outcome(round_.results)
786
+
787
+
788
+ def _try_load(path: Path):
789
+ try:
790
+ return product.load(path)
791
+ except (OSError, ValueError):
792
+ return None
793
+
794
+
795
+ def run_ask(args, providers, post, fetch=None) -> int:
796
+ reused = _reused(args, providers)
797
+ if reused is not None:
798
+ return reused
799
+ try:
800
+ providers, files, products, extractor = _preflight(args, providers, post)
801
+ except Refused as refusal:
802
+ print(f"askround: {refusal}", file=sys.stderr)
803
+ return REFUSED
804
+ if args.estimate_only:
805
+ return _estimate_only(args)
806
+ round_ = run_round(
807
+ args.question,
808
+ providers,
809
+ search=args.search,
810
+ runs=args.runs,
811
+ locales=args.locales,
812
+ on_answer=_recorder(args),
813
+ )
814
+ round_.brand = args.brand
815
+ round_.estimate = args.estimate
816
+ _after(args, round_, files, products, extractor, post, fetch)
817
+ directory = save_round(round_, _rounds_dir(args.out), products)
818
+ if args.json:
819
+ data = {**summary(round_, products), "saved": str(directory)}
820
+ print(json.dumps(data, ensure_ascii=False, indent=2))
821
+ else:
822
+ print(render(round_, directory, products))
823
+ return outcome(round_.results)
824
+
825
+
826
+ def _rounds_dir(out: Path | None) -> Path:
827
+ return out or Path(os.environ.get("ASKROUND_DIR", ".askround/rounds"))
828
+
829
+
830
+ def _post(post) -> dict:
831
+ return {"post": post} if post is not None else {}
832
+
833
+
834
+ def _fetch(fetch) -> dict:
835
+ return {"get": fetch} if fetch is not None else {}
836
+
837
+
838
+ def run_lookup(args, post) -> int:
839
+ checks = refusals.Checks()
840
+ for text in [*args.names, args.context]:
841
+ found = private.scan(text)
842
+ if found:
843
+ checks.add("question.personal-data", f"the lookup carries {', '.join(found)}: {text!r}")
844
+ try:
845
+ _refuse(checks)
846
+ except Refused as refusal:
847
+ print(f"askround: {refusal}", file=sys.stderr)
848
+ return REFUSED
849
+ web = lookup.web_lookup(**_post(post)) if args.web else None
850
+ verdicts = lookup.check(args.names, args.context, web=web, **_post(post))
851
+ if args.json:
852
+ data = {"schema": schema("lookup"), "verdicts": [v.__dict__ for v in verdicts]}
853
+ print(json.dumps(data, ensure_ascii=False, indent=2))
854
+ else:
855
+ for v in verdicts:
856
+ where = f" <{v.url}>" if v.url else ""
857
+ print(f"{v.name}: {v.status.replace('_', ' ')}{where}")
858
+ if v.evidence:
859
+ print(f" {v.evidence} ({v.via or 'no lookup'})")
860
+ return 0
861
+
862
+
863
+ def run_export(args) -> int:
864
+ items = export.rows(series.load(_rounds_dir(args.out)), args.product, args.since, args.until)
865
+ if args.format == "csv":
866
+ text = export.to_csv(items)
867
+ elif args.format == "json":
868
+ data = {"schema": schema("export"), "product": args.product, "rows": items}
869
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
870
+ else:
871
+ key = args.key or args.product.lower()
872
+ text = json.dumps(export.daily(items, key), ensure_ascii=False, indent=2) + "\n"
873
+ if args.file:
874
+ args.file.write_text(text, encoding="utf-8")
875
+ print(f"exported {len(items)} rows to {args.file}")
876
+ else:
877
+ print(text, end="")
878
+ return 0
879
+
880
+
881
+ def run_diff(args) -> int:
882
+ out = _rounds_dir(args.out)
883
+ try:
884
+ if args.old and args.new:
885
+ old = report_.find_round(args.old, out)
886
+ new = report_.find_round(args.new, out)
887
+ elif args.old or args.new:
888
+ raise FileNotFoundError("give both rounds to compare, or neither for the latest two")
889
+ else:
890
+ old, new = diff.latest_pair(out)
891
+ changes = diff.compare(old, new, args.product)
892
+ only = diff.unmatched(old, new)
893
+ except (FileNotFoundError, KeyError, ValueError) as error:
894
+ print(f"askround: {error}", file=sys.stderr)
895
+ return 2
896
+ if args.json:
897
+ data = {
898
+ "schema": schema("diff"),
899
+ "old": str(old),
900
+ "new": str(new),
901
+ "changes": [c.__dict__ for c in changes],
902
+ "only_old": only[0],
903
+ "only_new": only[1],
904
+ }
905
+ print(json.dumps(data, ensure_ascii=False, indent=2))
906
+ else:
907
+ print(diff.markdown(changes, old, new, only), end="")
908
+ return 0
909
+
910
+
911
+ def run_series(args) -> int:
912
+ out = _rounds_dir(args.out)
913
+ if args.rebuild:
914
+ skipped: list[str] = []
915
+ print(f"series rebuilt: {series.rebuild(out, skipped)} answers from the rounds under {out}")
916
+ for line in skipped:
917
+ print(f" skipped {line}", file=sys.stderr)
918
+ if not args.product:
919
+ return 0
920
+ if not args.product:
921
+ print("askround: name the product to follow with --product", file=sys.stderr)
922
+ return 2
923
+ table = series.frequency(series.load(out), args.product, args.by, args.question)
924
+ if args.json:
925
+ data = {
926
+ "schema": schema("series"),
927
+ "product": args.product,
928
+ "by": args.by,
929
+ "periods": table,
930
+ }
931
+ print(json.dumps(data, ensure_ascii=False, indent=2))
932
+ return 0
933
+ if not table:
934
+ print(f"no answers tracked {args.product} under {out}; ask with --product first")
935
+ return 0
936
+ providers = sorted({p for cells in table.values() for p in cells})
937
+ print(f"{args.product}: answers naming it, by {args.by}")
938
+ print("| " + args.by + " | " + " | ".join(providers) + " |")
939
+ print("|---|" + "---|" * len(providers))
940
+ for period, cells in table.items():
941
+ shown = []
942
+ for p in providers:
943
+ cell = cells.get(p)
944
+ shown.append(f"{cell['named']}/{cell['answers']}" if cell else "-")
945
+ print(f"| {period} | " + " | ".join(shown) + " |")
946
+ return 0
947
+
948
+
949
+ def run_report(args) -> int:
950
+ try:
951
+ path = report_.find_round(args.round, _rounds_dir(args.out))
952
+ if path.name == "set.json":
953
+ data = report_.build_set(path)
954
+ text = report_.set_markdown(data)
955
+ else:
956
+ round_, asked = report_.load_round(path)
957
+ if args.brief:
958
+ found = report_.brief(round_, args.products or asked, str(path.parent))
959
+ print(json.dumps(found, ensure_ascii=False, separators=(",", ":")))
960
+ return 0
961
+ data = report_.build(round_, args.products or asked, str(path))
962
+ text = report_.markdown(data)
963
+ except (FileNotFoundError, KeyError, ValueError) as error:
964
+ print(f"askround: {error}", file=sys.stderr)
965
+ return 2
966
+ if args.json:
967
+ print(json.dumps(data, ensure_ascii=False, indent=2))
968
+ else:
969
+ print(text, end="")
970
+ return 0
971
+
972
+
973
+ def run_doctor(providers, check: bool, as_json_: bool) -> int:
974
+ statuses = diagnose(providers, check=check)
975
+ if as_json_:
976
+ data = {"schema": schema("doctor"), "providers": [_status_json(s) for s in statuses]}
977
+ print(json.dumps(data, indent=2))
978
+ else:
979
+ print(render_doctor(statuses))
980
+ return 0 if any(s.can_run for s in statuses) else 2
981
+
982
+
983
+ def _status_json(s: Status) -> dict:
984
+ return {
985
+ "provider": s.provider,
986
+ "model": s.model,
987
+ "search_tool": s.search_tool,
988
+ "key_var": s.key_var,
989
+ "checked": s.checked,
990
+ "ok": s.ok,
991
+ "can_run": s.can_run,
992
+ "detail": s.detail,
993
+ }
994
+
995
+
996
+ def render_doctor(statuses: list[Status]) -> str:
997
+ rows = [("provider", "key", "model", "search", "status")]
998
+ for s in statuses:
999
+ mark = "ok" if s.ok else ("-" if not s.checked else "FAIL")
1000
+ status = s.detail if s.detail == "ok" else f"{mark}: {s.detail}"
1001
+ rows.append((s.provider, s.key_var or "-", s.model, s.search_tool, status))
1002
+ widths = [max(len(row[i]) for row in rows) for i in range(4)]
1003
+ lines = [
1004
+ " ".join(cell.ljust(w) for cell, w in zip(row[:4], widths, strict=True)) + " " + row[4]
1005
+ for row in rows
1006
+ ]
1007
+ can = [s.provider for s in statuses if s.can_run]
1008
+ lines.append("")
1009
+ lines.append(f"can run: {', '.join(can)}" if can else "no provider can run")
1010
+ return "\n".join(lines)
1011
+
1012
+
1013
+ def render(round_: Round, directory: Path, products: list[list[str]] | None = None) -> str:
1014
+ lines = [f"Q: {round_.question}", ""]
1015
+ if not round_.search:
1016
+ lines[0] += " (web search off)"
1017
+ for r in round_.results:
1018
+ run = f", run {r.run}/{round_.runs}" if round_.runs > 1 else ""
1019
+ lines.append(f"=== {r.provider} ({_model(r)}{run}){_place(r)} ===")
1020
+ lines.extend(_answer_lines(r.answer) if r.answer else [f"[error] {r.error}"])
1021
+ lines.append("")
1022
+ for names in products or []:
1023
+ lines.extend(_frequency_lines(round_, names))
1024
+ lines.append("")
1025
+ lines.extend(_rival_lines(round_, products or []))
1026
+ failed = failures(round_)
1027
+ if failed:
1028
+ lines.append(f"failed: {len(failed)} of {len(round_.results)} answers")
1029
+ for r in failed:
1030
+ where = f" {r.locale}" if r.locale else ""
1031
+ lines.append(f" {r.provider}{where} run {r.run}: {r.error}")
1032
+ lines.append("")
1033
+ lines.append(_cost_line(cost(round_)))
1034
+ lines.append(f"saved: {directory}")
1035
+ return "\n".join(lines)
1036
+
1037
+
1038
+ def _cost_line(spent: dict) -> str:
1039
+ """'cost: $0.0412 (openai $0.0301, ...), estimated $0.05' from round.cost()."""
1040
+ actual = spent["actual"] or {}
1041
+ parts = ", ".join(
1042
+ f"{k} ${v:.4f}" if v is not None else f"{k} (no price)" for k, v in actual.items()
1043
+ )
1044
+ total = f"${spent['total']:.4f}" if spent["total"] is not None else "unknown"
1045
+ estimate = spent["estimate"]
1046
+ guessed = f", estimated ${estimate['total']:.4f}" if estimate else ""
1047
+ return f"cost: {total} ({parts or 'nothing billed'}){guessed}"
1048
+
1049
+
1050
+ RIVALS_SHOWN = 10
1051
+
1052
+
1053
+ def _rival_lines(round_: Round, products: list[list[str]]) -> list[str]:
1054
+ found = rivals(round_, products)
1055
+ if not found.read or not found.rivals:
1056
+ return []
1057
+ title = "rivals" if products else "recommended"
1058
+ lines = [f"{title} (in {found.read} answers read):"]
1059
+ for rival in found.rivals[:RIVALS_SHOWN]:
1060
+ ranks = ", ".join(f"#{rank}" for rank in rival.ranks)
1061
+ by = ", ".join(f"{p} {n}" for p, n in rival.providers.items())
1062
+ verdict = round_.checks.get(rival_key(rival.name))
1063
+ tag = f" [{verdict.status.replace('_', ' ')}]" if verdict else ""
1064
+ lines.append(
1065
+ f" {rival.name:<24} {rival.answers}/{found.read} ranks: {ranks} ({by}){tag}"
1066
+ )
1067
+ hidden = len(found.rivals) - RIVALS_SHOWN
1068
+ if hidden > 0:
1069
+ lines.append(f" ... and {hidden} more in round.json")
1070
+ lines.append("")
1071
+ return lines
1072
+
1073
+
1074
+ def _model(r) -> str:
1075
+ served = r.answer.served_model if r.answer else ""
1076
+ return f"{r.model} -> {served}" if served and served != r.model else r.model
1077
+
1078
+
1079
+ def _place(r) -> str:
1080
+ if r.locale is None:
1081
+ return ""
1082
+ ignored = r.answer.locale_ignored if r.answer else []
1083
+ note = f", {' and '.join(ignored)} ignored by the API" if ignored else ""
1084
+ return f" [{r.locale}{note}]"
1085
+
1086
+
1087
+ def _frequency_lines(round_: Round, names: list[str]) -> list[str]:
1088
+ lines = [f"{names[0]}: named in"]
1089
+ extracted = False
1090
+ for f in frequency(round_, names):
1091
+ marks = ["*" if by == "extraction" else "" for by in f.ranked_by]
1092
+ extracted = extracted or any(marks)
1093
+ places = ", ".join(
1094
+ "-" if p is None else f"#{p}{mark}" for p, mark in zip(f.positions, marks, strict=True)
1095
+ )
1096
+ where = f"{str(f.locale):<9}" if round_.locales else ""
1097
+ lines.append(
1098
+ f" {f.provider:<11} {where}{f.share} runs positions: {places or '-'} ({f.model})"
1099
+ )
1100
+ if extracted:
1101
+ lines.append(" * ranked by the extraction pass")
1102
+ lines.extend(_claim_lines(round_, names))
1103
+ lines.extend(_cited_lines(round_, names))
1104
+ lines.extend(_gap_lines(round_, names[0]))
1105
+ lines.extend(_link_lines(round_, names[0]))
1106
+ return lines
1107
+
1108
+
1109
+ def _gap_lines(round_: Round, product: str) -> list[str]:
1110
+ found = round_.gaps.get(product)
1111
+ if found is None:
1112
+ return []
1113
+ if not found:
1114
+ return [f"{product}: no fact gaps; every claim compared agrees with its facts"]
1115
+ lines = [f"{product}: fact gaps"]
1116
+ for g in found:
1117
+ by = ", ".join(f"{p} {n}" for p, n in g.providers.items())
1118
+ lines.append(f' {g.attribute}: {g.value} ({by}) {g.verdict} "{g.fact}"')
1119
+ lines.append(f" {g.why}")
1120
+ if g.page:
1121
+ lines.append(f" say it plainly on {g.page}")
1122
+ return lines
1123
+
1124
+
1125
+ def _link_lines(round_: Round, product: str) -> list[str]:
1126
+ checks = round_.links.get(product)
1127
+ if checks is None:
1128
+ return []
1129
+ wrong = [c for c in checks if c.verdict != "ok"]
1130
+ lines = [f"{product}: links given for it, {len(checks)} followed, {len(wrong)} not its own"]
1131
+ for c in wrong:
1132
+ lands = (c.title or c.final_url) if c.status else "unreachable"
1133
+ lines.append(f" {c.provider} run {c.run}: {c.url}")
1134
+ lines.append(f" {c.verdict}: {lands}")
1135
+ return lines
1136
+
1137
+
1138
+ def _claim_lines(round_: Round, names: list[str]) -> list[str]:
1139
+ found = product_claims(round_, names)
1140
+ if not found:
1141
+ return []
1142
+ lines = [f"{names[0]}: what the answers claim"]
1143
+ for c in found:
1144
+ if c.answers:
1145
+ by = ", ".join(f"{p} {n}" for p, n in c.providers.items())
1146
+ lines.append(f" {c.attribute}: {c.value} ({by})")
1147
+ lines.append(f' "{c.quote}"')
1148
+ unverified = sum(c.unverified for c in found)
1149
+ if unverified:
1150
+ lines.append(f" ({unverified} more not found in the answer's text, in round.json)")
1151
+ return lines
1152
+
1153
+
1154
+ def _cited_lines(round_: Round, names: list[str]) -> list[str]:
1155
+ if not citations.places(names):
1156
+ return [
1157
+ f" (give {names[0]}'s domain, e.g. --product {names[0]},example.com, to see "
1158
+ "which of its pages are cited)"
1159
+ ]
1160
+ lines = [f"{names[0]}: its pages cited in"]
1161
+ for c in citations.product_citations(round_, names):
1162
+ kinds = ", ".join(f"{k} {n}" for k, n in sorted(c.kinds.items())) or "none"
1163
+ lines.append(f" {c.provider:<11} {c.citing}/{c.answers} answers ({kinds})")
1164
+ for page, n in sorted(c.pages.items(), key=lambda kv: -kv[1]):
1165
+ lines.append(f" {n}x {page}")
1166
+ return lines
1167
+
1168
+
1169
+ def _answer_lines(answer: Answer) -> list[str]:
1170
+ lines = [answer.text]
1171
+ if answer.recommendations:
1172
+ named = [rec.name + ("" if rec.verified else " (?)") for rec in answer.recommendations]
1173
+ lines += ["", "recommends: " + ", ".join(f"{i}. {n}" for i, n in enumerate(named, 1))]
1174
+ elif answer.extraction_error:
1175
+ lines += ["", f"[extraction failed] {answer.extraction_error}"]
1176
+ if answer.sources:
1177
+ lines += ["", "sources:"]
1178
+ lines += [f" - {s.title} <{s.url}>" if s.title else f" - {s.url}" for s in answer.sources]
1179
+ return lines