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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """askround: ask several AI assistants the same question and keep their answers."""
2
+
3
+ __version__ = "0.1.0"
askround/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from askround.cli import main
2
+
3
+ raise SystemExit(main())
askround/budget.py ADDED
@@ -0,0 +1,108 @@
1
+ """The person's budget: what askround may spend a day and a month, and what it has spent.
2
+
3
+ askround spends money on the user's keys, and an agent with the MCP tools can run rounds
4
+ on its own, in a loop. So the person sets a budget in askround's config file, which no
5
+ MCP tool writes (law L5: the person, never the agent, sets the money):
6
+
7
+ # %APPDATA%\\askround\\config.toml on Windows, ~/.config/askround/config.toml
8
+ # elsewhere, or the file ASKROUND_CONFIG names
9
+ [budget]
10
+ day = 1.00 # USD
11
+ month = 10.00
12
+
13
+ Every saved round adds its actual cost to a ledger, `spent.jsonl` beside the rounds. A
14
+ run whose estimate would take the day's or the month's spending past the budget is not
15
+ run: it is refused before any call, with the estimate, the spending so far and the
16
+ budget, as a request for the person's approval. Only the person approves - with
17
+ `--approve` on the command line, which the MCP tools do not offer - or raises the budget.
18
+ Days and months are counted in UTC.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ import tomllib
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+
31
+ LEDGER = "spent.jsonl"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Budget:
36
+ day: float | None = None
37
+ month: float | None = None
38
+
39
+
40
+ def config_path() -> Path:
41
+ if os.environ.get("ASKROUND_CONFIG"):
42
+ return Path(os.environ["ASKROUND_CONFIG"])
43
+ if sys.platform == "win32" and os.environ.get("APPDATA"):
44
+ return Path(os.environ["APPDATA"]) / "askround" / "config.toml"
45
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
46
+ return Path(base) / "askround" / "config.toml"
47
+
48
+
49
+ def load() -> Budget:
50
+ path = config_path()
51
+ if not path.is_file():
52
+ return Budget()
53
+ data = tomllib.loads(path.read_text(encoding="utf-8")).get("budget") or {}
54
+ return Budget(_amount(data.get("day")), _amount(data.get("month")))
55
+
56
+
57
+ def _amount(value) -> float | None:
58
+ return float(value) if isinstance(value, int | float) and value >= 0 else None
59
+
60
+
61
+ def record(out: Path, cost: float | None, question: str, now: datetime | None = None) -> None:
62
+ """Add a saved round's actual cost to the ledger (a cost the table cannot price is 0)."""
63
+ out.mkdir(parents=True, exist_ok=True)
64
+ moment = (now or datetime.now(UTC)).astimezone(UTC)
65
+ entry = {"at": moment.isoformat(), "cost": round(cost or 0.0, 6), "question": question[:120]}
66
+ with (out / LEDGER).open("a", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
68
+
69
+
70
+ def spent(out: Path, now: datetime | None = None) -> tuple[float, float]:
71
+ """USD spent today and this month, from the ledger."""
72
+ moment = (now or datetime.now(UTC)).astimezone(UTC)
73
+ day, month = moment.strftime("%Y-%m-%d"), moment.strftime("%Y-%m")
74
+ today = this_month = 0.0
75
+ path = out / LEDGER
76
+ if path.is_file():
77
+ for line in path.read_text(encoding="utf-8").splitlines():
78
+ if not line.strip():
79
+ continue
80
+ entry = json.loads(line)
81
+ if entry["at"][:7] == month:
82
+ this_month += entry["cost"]
83
+ if entry["at"][:10] == day:
84
+ today += entry["cost"]
85
+ return round(today, 6), round(this_month, 6)
86
+
87
+
88
+ def status(out: Path, now: datetime | None = None) -> dict:
89
+ """What has been spent against the budget; reported with every paid result."""
90
+ today, month = spent(out, now)
91
+ limits = load()
92
+ return {
93
+ "today": today,
94
+ "month": month,
95
+ "budget": {"day": limits.day, "month": limits.month},
96
+ "config": str(config_path()),
97
+ }
98
+
99
+
100
+ def over(out: Path, estimate: float, now: datetime | None = None) -> str | None:
101
+ """Why this run would pass the budget, or None when it fits (or there is none)."""
102
+ limits = load()
103
+ today, month = spent(out, now)
104
+ if limits.day is not None and today + estimate > limits.day:
105
+ return f"today ${today:.4f} spent + ${estimate:.4f} estimated > the day's budget ${limits.day:.2f}"
106
+ if limits.month is not None and month + estimate > limits.month:
107
+ return f"this month ${month:.4f} spent + ${estimate:.4f} estimated > the month's budget ${limits.month:.2f}"
108
+ return None
askround/citations.py ADDED
@@ -0,0 +1,172 @@
1
+ """Which pages an answer cites, and which of them are the product's own.
2
+
3
+ An answer's citations are the sources its provider reported (AR2) plus the links
4
+ written in its text, one per page: URLs are normalised so the same page counts once
5
+ (tracking parameters such as OpenAI's `utm_source=openai` and fragments dropped, the
6
+ host lower-cased, `www.` removed). Gemini reports its sources as
7
+ `vertexaisearch.cloud.google.com/grounding-api-redirect/...` links titled with the
8
+ domain; askround reads the domain from the title rather than following each redirect,
9
+ so a Gemini citation is known by its site, not its page.
10
+
11
+ A product's places are the names given for it that contain a dot: a domain
12
+ (`modelado.app`, which covers its subdomains) or a URL prefix
13
+ (`github.com/alegauss/modelado`). A cited page on one of them is the product's, and is
14
+ classified by kind - repository (GitHub, GitLab, Hugging Face, ...), store (Microsoft
15
+ Store, Google Play, App Store, ...), docs (a `docs.` host or a `/docs` path) or site.
16
+ Every other page is third-party, counted by domain, and attributed to a rival when the
17
+ rival's name is in its address or title.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
25
+
26
+ from askround import mention
27
+
28
+ REDIRECT_HOSTS = ("vertexaisearch.cloud.google.com",)
29
+ _TRACKING = re.compile(r"^(utm_\w+|ref|ref_src|fbclid|gclid)$", re.IGNORECASE)
30
+ _LINK = re.compile(r"https?://[^\s)\]>\"'`]+")
31
+
32
+ REPOSITORY_HOSTS = (
33
+ "github.com",
34
+ "gitlab.com",
35
+ "bitbucket.org",
36
+ "huggingface.co",
37
+ "sourceforge.net",
38
+ )
39
+ STORE_HOSTS = (
40
+ "apps.microsoft.com",
41
+ "microsoft.com/store",
42
+ "microsoft.com/p/",
43
+ "play.google.com",
44
+ "apps.apple.com",
45
+ "store.steampowered.com",
46
+ "itch.io",
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Cited:
52
+ url: str # normalised; for a redirect, "https://<domain>/" read from its title
53
+ host: str
54
+ title: str = ""
55
+ redirect: bool = False
56
+
57
+
58
+ def _host(netloc: str) -> str:
59
+ host = netloc.lower().split("@")[-1].split(":")[0]
60
+ return host[4:] if host.startswith("www.") else host
61
+
62
+
63
+ def normalise(url: str, title: str = "") -> Cited | None:
64
+ parts = urlsplit(url.strip().rstrip(".,;"))
65
+ if parts.scheme not in ("http", "https") or not parts.netloc:
66
+ return None
67
+ host = _host(parts.netloc)
68
+ if host in REDIRECT_HOSTS:
69
+ domain = _host(title.strip()) if "." in title else ""
70
+ if not domain:
71
+ return None
72
+ return Cited(f"https://{domain}/", domain, title, redirect=True)
73
+ query = urlencode([(k, v) for k, v in parse_qsl(parts.query) if not _TRACKING.match(k)])
74
+ path = parts.path.rstrip("/") or "/"
75
+ return Cited(urlunsplit(("https", host, path, query, "")), host, title)
76
+
77
+
78
+ def cited(answer) -> list[Cited]:
79
+ """The answer's pages: its reported sources, then the links in its text."""
80
+ pages: dict[str, Cited] = {}
81
+ for source in answer.sources:
82
+ page = normalise(source.url, source.title)
83
+ if page:
84
+ pages.setdefault(page.url, page)
85
+ for link in _LINK.findall(answer.text):
86
+ page = normalise(link)
87
+ if page:
88
+ pages.setdefault(page.url, page)
89
+ return list(pages.values())
90
+
91
+
92
+ def places(names: list[str]) -> list[str]:
93
+ """The product's own places among its names: "modelado.app", "github.com/x/y"."""
94
+ return [
95
+ n.lower().removeprefix("https://").removeprefix("http://").removeprefix("www.").rstrip("/")
96
+ for n in names
97
+ if "." in n
98
+ ]
99
+
100
+
101
+ def _address(page: Cited) -> str:
102
+ rest = urlsplit(page.url).path
103
+ return f"{page.host}{'' if rest == '/' else rest}"
104
+
105
+
106
+ def owns(place: str, page: Cited) -> bool:
107
+ if "/" not in place:
108
+ return page.host == place or page.host.endswith("." + place)
109
+ return (_address(page) + "/").lower().startswith(place + "/") or _address(page).lower() == place
110
+
111
+
112
+ def kind(page: Cited) -> str:
113
+ address = _address(page).lower()
114
+ if any(address.startswith(h) or page.host == h for h in REPOSITORY_HOSTS):
115
+ return "repository"
116
+ if any(address.startswith(h) for h in STORE_HOSTS):
117
+ return "store"
118
+ if page.host.startswith("docs.") or "/docs" in address:
119
+ return "docs"
120
+ return "site"
121
+
122
+
123
+ @dataclass
124
+ class ProductCitations:
125
+ """How one provider's answers cited one product's pages."""
126
+
127
+ provider: str
128
+ answers: int = 0
129
+ citing: int = 0 # answers that cited at least one of the product's pages
130
+ kinds: dict[str, int] = field(default_factory=dict) # kind -> answers citing it
131
+ pages: dict[str, int] = field(default_factory=dict) # url -> answers citing it
132
+
133
+
134
+ def product_citations(round_, names: list[str]) -> list[ProductCitations]:
135
+ own = places(names)
136
+ table: dict[str, ProductCitations] = {}
137
+ for r in round_.results:
138
+ row = table.setdefault(r.provider, ProductCitations(r.provider))
139
+ if r.answer is None:
140
+ continue
141
+ row.answers += 1
142
+ mine = [p for p in cited(r.answer) if any(owns(place, p) for place in own)]
143
+ if mine:
144
+ row.citing += 1
145
+ for k in {kind(p) for p in mine}:
146
+ row.kinds[k] = row.kinds.get(k, 0) + 1
147
+ for page in {p.url for p in mine}:
148
+ row.pages[page] = row.pages.get(page, 0) + 1
149
+ return list(table.values())
150
+
151
+
152
+ def third_party(round_, products: list[list[str]], rival_names: list[str]) -> dict:
153
+ """Pages that are no product's own: answers citing each domain, and each rival's."""
154
+ own = [place for names in products for place in places(names)]
155
+ domains: dict[str, int] = {}
156
+ by_rival: dict[str, int] = {}
157
+ for r in round_.results:
158
+ if r.answer is None:
159
+ continue
160
+ pages = [p for p in cited(r.answer) if not any(owns(place, p) for place in own)]
161
+ for host in {p.host for p in pages}:
162
+ domains[host] = domains.get(host, 0) + 1
163
+ for rival in rival_names:
164
+ needle = mention.core(rival).lower()
165
+ squashed = needle.replace(" ", "")
166
+ if any(
167
+ squashed in _address(p).lower().replace("-", "") or needle in p.title.lower()
168
+ for p in pages
169
+ ):
170
+ by_rival[rival] = by_rival.get(rival, 0) + 1
171
+ ranked = dict(sorted(domains.items(), key=lambda kv: (-kv[1], kv[0])))
172
+ return {"domains": ranked, "rivals": by_rival}
askround/claims.py ADDED
@@ -0,0 +1,61 @@
1
+ """What a round's answers claim about one product, aggregated.
2
+
3
+ The extraction pass (extract.py) reads every claim an answer makes about the products
4
+ it names, as attribute and value with a verbatim quote. For one product, this groups
5
+ them by attribute and value - so 'CPU only: Check build' in two answers is one line
6
+ seen twice - with the providers that made each and the first verified quote as its
7
+ passage. A claim whose quote is not in the answer is counted apart as unverified,
8
+ never mixed with the ones that trace to their passage (law L4).
9
+
10
+ Attributes are grouped case-insensitively and with whitespace collapsed; the spelling
11
+ first seen is the one shown.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from askround import mention
19
+ from askround.provenance import answer_id, span
20
+
21
+
22
+ @dataclass
23
+ class ProductClaim:
24
+ attribute: str
25
+ value: str
26
+ answers: int = 0
27
+ providers: dict[str, int] = field(default_factory=dict)
28
+ quote: str = ""
29
+ unverified: int = 0
30
+ # each answer that made it: {"answer": "openai#2", "range": [start, end]}
31
+ found_in: list[dict] = field(default_factory=list)
32
+
33
+
34
+ def _fold(text: str) -> str:
35
+ return " ".join(text.lower().split())
36
+
37
+
38
+ def product_claims(round_, names: list[str]) -> list[ProductClaim]:
39
+ target = mention.pattern(names)
40
+ table: dict[tuple[str, str], ProductClaim] = {}
41
+ for r in round_.results:
42
+ answer = r.answer
43
+ if answer is None or not answer.claims:
44
+ continue
45
+ seen: set[tuple[str, str]] = set()
46
+ for claim in answer.claims:
47
+ if not target.search(claim.product):
48
+ continue
49
+ k = (_fold(claim.attribute), _fold(claim.value))
50
+ row = table.setdefault(k, ProductClaim(claim.attribute, claim.value))
51
+ if not claim.verified:
52
+ row.unverified += 1
53
+ continue
54
+ if k in seen:
55
+ continue
56
+ seen.add(k)
57
+ row.answers += 1
58
+ row.providers[r.provider] = row.providers.get(r.provider, 0) + 1
59
+ row.quote = row.quote or claim.quote
60
+ row.found_in.append({"answer": answer_id(r), "range": span(answer.text, claim.quote)})
61
+ return sorted(table.values(), key=lambda c: (-c.answers, _fold(c.attribute), _fold(c.value)))