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 +3 -0
- askround/__main__.py +3 -0
- askround/budget.py +108 -0
- askround/citations.py +172 -0
- askround/claims.py +61 -0
- askround/cli.py +1179 -0
- askround/costs.py +206 -0
- askround/diff.py +170 -0
- askround/discover.py +124 -0
- askround/doctor.py +65 -0
- askround/export.py +113 -0
- askround/extract.py +288 -0
- askround/gaps.py +117 -0
- askround/http.py +94 -0
- askround/jobs.py +142 -0
- askround/leading.py +71 -0
- askround/links.py +153 -0
- askround/lookup.py +246 -0
- askround/mcp.py +538 -0
- askround/mention.py +112 -0
- askround/private.py +67 -0
- askround/product.py +114 -0
- askround/provenance.py +44 -0
- askround/providers.py +454 -0
- askround/refusals.py +104 -0
- askround/report.py +531 -0
- askround/research.py +296 -0
- askround/reuse.py +96 -0
- askround/rivals.py +79 -0
- askround/round.py +467 -0
- askround/series.py +142 -0
- askround/sets.py +122 -0
- askround/wiring.py +162 -0
- askround-0.1.0.dist-info/METADATA +452 -0
- askround-0.1.0.dist-info/RECORD +39 -0
- askround-0.1.0.dist-info/WHEEL +5 -0
- askround-0.1.0.dist-info/entry_points.txt +2 -0
- askround-0.1.0.dist-info/licenses/LICENSE +201 -0
- askround-0.1.0.dist-info/top_level.txt +1 -0
askround/__init__.py
ADDED
askround/__main__.py
ADDED
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)))
|