dromad 0.1.0__tar.gz
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.
- dromad-0.1.0/LICENSE +21 -0
- dromad-0.1.0/PKG-INFO +45 -0
- dromad-0.1.0/README.md +19 -0
- dromad-0.1.0/pyproject.toml +48 -0
- dromad-0.1.0/src/dromad/__init__.py +14 -0
- dromad-0.1.0/src/dromad/cli/__init__.py +0 -0
- dromad-0.1.0/src/dromad/cli/analyses.py +62 -0
- dromad-0.1.0/src/dromad/cli/audit.py +167 -0
- dromad-0.1.0/src/dromad/cli/auth.py +136 -0
- dromad-0.1.0/src/dromad/cli/citations.py +133 -0
- dromad-0.1.0/src/dromad/cli/exit_codes.py +14 -0
- dromad-0.1.0/src/dromad/cli/fanout.py +89 -0
- dromad-0.1.0/src/dromad/cli/main.py +49 -0
- dromad-0.1.0/src/dromad/cli/open.py +43 -0
- dromad-0.1.0/src/dromad/cli/output.py +78 -0
- dromad-0.1.0/src/dromad/cli/prompt.py +50 -0
- dromad-0.1.0/src/dromad/cli/render.py +101 -0
- dromad-0.1.0/src/dromad/cli/runs.py +81 -0
- dromad-0.1.0/src/dromad/cli/share.py +57 -0
- dromad-0.1.0/src/dromad/client/__init__.py +165 -0
- dromad-0.1.0/src/dromad/client/errors.py +49 -0
- dromad-0.1.0/src/dromad/client/polling.py +31 -0
- dromad-0.1.0/src/dromad/client/transport.py +76 -0
- dromad-0.1.0/src/dromad/config.py +121 -0
- dromad-0.1.0/src/dromad/schemas/__init__.py +7 -0
- dromad-0.1.0/src/dromad/schemas/analyses.py +98 -0
- dromad-0.1.0/src/dromad/schemas/audit.py +184 -0
- dromad-0.1.0/src/dromad/schemas/auth.py +103 -0
- dromad-0.1.0/src/dromad/schemas/batches.py +41 -0
- dromad-0.1.0/src/dromad/schemas/citations.py +145 -0
- dromad-0.1.0/src/dromad/schemas/errors.py +35 -0
- dromad-0.1.0/src/dromad/schemas/fanout.py +62 -0
- dromad-0.1.0/src/dromad/schemas/ids.py +69 -0
- dromad-0.1.0/src/dromad/schemas/prompt.py +21 -0
- dromad-0.1.0/src/dromad/schemas/runs.py +93 -0
- dromad-0.1.0/src/dromad/schemas/sharing.py +27 -0
- dromad-0.1.0/src/dromad/schemas/sources.py +55 -0
dromad-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Sillings
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dromad-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dromad
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GEO research tools for coding agents: multi-engine prompt runs, fanout, citation analysis and page audits
|
|
5
|
+
Keywords: geo,ai-search,citations,llm,cli,agents
|
|
6
|
+
Author: John Sillings
|
|
7
|
+
Author-email: John Sillings <john@johnsillings.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Dist: typer>=0.12
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://dromad.dev
|
|
22
|
+
Project-URL: Documentation, https://dromad.dev/docs
|
|
23
|
+
Project-URL: CLI reference, https://dromad.dev/docs/cli
|
|
24
|
+
Project-URL: For agents, https://dromad.dev/docs/agents
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# dromad
|
|
28
|
+
|
|
29
|
+
The Dromad command-line client: GEO research tools built primarily for coding agents.
|
|
30
|
+
|
|
31
|
+
Dromad asks ChatGPT, Claude and Gemini the questions your customers ask, with each engine's web search on, and records what each one answered and which sources it cited. It counts those citations across runs, generates the questions worth asking, and audits the pages involved. Dromad records evidence and counts it; the reasoning over it is yours or your agent's.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install dromad # or: uv tool install dromad · pipx install dromad
|
|
35
|
+
dromad login # opens the browser to approve this device
|
|
36
|
+
dromad prompt "best search APIs for AI agents"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`dromad` talks to the hosted service at [dromad.dev](https://dromad.dev); you need an account there, and sign-up is by invitation for now. Every command prints readable output by default and one stable JSON document with `--json`, so an agent can drive it directly. For CI and agents without a browser, set `DROMAD_API_KEY` instead of logging in.
|
|
40
|
+
|
|
41
|
+
- [Docs](https://dromad.dev/docs): what Dromad records, the quickstart, and what it costs.
|
|
42
|
+
- [CLI reference](https://dromad.dev/docs/cli): every command, with real output.
|
|
43
|
+
- [For agents](https://dromad.dev/docs/agents): which command when, and how they compose.
|
|
44
|
+
|
|
45
|
+
This package contains the command, the HTTP client under it, and the pydantic schemas that define the API contract. It depends on typer, httpx and pydantic, and nothing else. Python 3.12 or newer. MIT licensed.
|
dromad-0.1.0/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# dromad
|
|
2
|
+
|
|
3
|
+
The Dromad command-line client: GEO research tools built primarily for coding agents.
|
|
4
|
+
|
|
5
|
+
Dromad asks ChatGPT, Claude and Gemini the questions your customers ask, with each engine's web search on, and records what each one answered and which sources it cited. It counts those citations across runs, generates the questions worth asking, and audits the pages involved. Dromad records evidence and counts it; the reasoning over it is yours or your agent's.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install dromad # or: uv tool install dromad · pipx install dromad
|
|
9
|
+
dromad login # opens the browser to approve this device
|
|
10
|
+
dromad prompt "best search APIs for AI agents"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`dromad` talks to the hosted service at [dromad.dev](https://dromad.dev); you need an account there, and sign-up is by invitation for now. Every command prints readable output by default and one stable JSON document with `--json`, so an agent can drive it directly. For CI and agents without a browser, set `DROMAD_API_KEY` instead of logging in.
|
|
14
|
+
|
|
15
|
+
- [Docs](https://dromad.dev/docs): what Dromad records, the quickstart, and what it costs.
|
|
16
|
+
- [CLI reference](https://dromad.dev/docs/cli): every command, with real output.
|
|
17
|
+
- [For agents](https://dromad.dev/docs/agents): which command when, and how they compose.
|
|
18
|
+
|
|
19
|
+
This package contains the command, the HTTP client under it, and the pydantic schemas that define the API contract. It depends on typer, httpx and pydantic, and nothing else. Python 3.12 or newer. MIT licensed.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dromad"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "GEO research tools for coding agents: multi-engine prompt runs, fanout, citation analysis and page audits"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
# The client only. The hosted service it talks to is not part of this package.
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
authors = [{ name = "John Sillings", email = "john@johnsillings.com" }]
|
|
11
|
+
keywords = ["geo", "ai-search", "citations", "llm", "cli", "agents"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
# The whole dependency surface of `pip install dromad`. The hosted service is a
|
|
22
|
+
# separate distribution; nothing here may pull a web framework or ORM.
|
|
23
|
+
# tests/test_structure.py pins this list.
|
|
24
|
+
dependencies = [
|
|
25
|
+
"typer>=0.12",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"pydantic>=2.7",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://dromad.dev"
|
|
32
|
+
Documentation = "https://dromad.dev/docs"
|
|
33
|
+
"CLI reference" = "https://dromad.dev/docs/cli"
|
|
34
|
+
"For agents" = "https://dromad.dev/docs/agents"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
dromad = "dromad.cli.main:app"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.11.0,<0.12"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
python_files = ["test_*.py"]
|
|
46
|
+
# The server's pytest plugin is installed in the shared workspace venv. It must
|
|
47
|
+
# not configure a Django project inside the client's suite.
|
|
48
|
+
addopts = "-p no:django"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Dromad: GEO research tools for coding agents.
|
|
2
|
+
|
|
3
|
+
This distribution is the client half of Dromad: the ``dromad`` command, the
|
|
4
|
+
HTTP client under it, and the pydantic schemas in :mod:`dromad.schemas` that
|
|
5
|
+
are the contract with the hosted service. The service imports those schemas;
|
|
6
|
+
nothing here imports the service.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("dromad")
|
|
13
|
+
except PackageNotFoundError: # a source tree on sys.path without an install
|
|
14
|
+
__version__ = "0.0.0+unknown"
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``dromad analyses list`` and ``dromad analyses show``."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dromad import config
|
|
6
|
+
from dromad.cli.citations import render_citations
|
|
7
|
+
from dromad.cli.fanout import render_fanout
|
|
8
|
+
from dromad.cli.output import emit_json, errors_reported, table
|
|
9
|
+
from dromad.client import Client
|
|
10
|
+
from dromad.schemas.analyses import CitationAnalysis
|
|
11
|
+
from dromad.schemas.ids import IdKind, is_id
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Inspect analyses: results derived from runs.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
JSON_OPTION = typer.Option(False, "--json", help="Print one JSON document to stdout.")
|
|
16
|
+
API_URL_OPTION = typer.Option(None, "--api-url", help="Dromad server.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list_analyses(
|
|
21
|
+
kind: str | None = typer.Option(None, "--kind", help="Only one kind: fanout or citations."),
|
|
22
|
+
limit: int = typer.Option(20, "--limit", min=1, max=200),
|
|
23
|
+
api_url: str | None = API_URL_OPTION,
|
|
24
|
+
as_json: bool = JSON_OPTION,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Your analyses, newest first."""
|
|
27
|
+
with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
|
|
28
|
+
listing = client.list_analyses(kind=kind, limit=limit)
|
|
29
|
+
if as_json:
|
|
30
|
+
emit_json(listing)
|
|
31
|
+
return
|
|
32
|
+
if not listing.items:
|
|
33
|
+
typer.echo(
|
|
34
|
+
'No analyses yet. Generate your first questions:\n\n dromad fanout "AI search API"'
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
rows = [
|
|
38
|
+
[a.id, a.kind, a.status, a.run_count, a.created_at.strftime("%Y-%m-%d %H:%M"), a.subject]
|
|
39
|
+
for a in listing.items
|
|
40
|
+
]
|
|
41
|
+
typer.echo(table(rows, ["ID", "KIND", "STATUS", "RUNS", "CREATED", "SUBJECT"]))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("show")
|
|
45
|
+
def show(
|
|
46
|
+
analysis_id: str = typer.Argument(..., help="An analysis ID (fa_… or ca_…)."),
|
|
47
|
+
api_url: str | None = API_URL_OPTION,
|
|
48
|
+
as_json: bool = JSON_OPTION,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""One analysis, in full."""
|
|
51
|
+
if not is_id(analysis_id, IdKind.FANOUT_ANALYSIS, IdKind.CITATION_ANALYSIS):
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
f"{analysis_id!r} is not an analysis ID (fa_… or ca_…)", param_hint="ANALYSIS_ID"
|
|
54
|
+
)
|
|
55
|
+
with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
|
|
56
|
+
analysis = client.get_analysis(analysis_id)
|
|
57
|
+
if as_json:
|
|
58
|
+
emit_json(analysis)
|
|
59
|
+
elif isinstance(analysis, CitationAnalysis):
|
|
60
|
+
typer.echo(render_citations(analysis))
|
|
61
|
+
else:
|
|
62
|
+
typer.echo(render_fanout(analysis))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""``dromad audit`` — what one page looks like to a crawler, and what follows."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from dromad import config
|
|
8
|
+
from dromad.cli.exit_codes import ExitCode
|
|
9
|
+
from dromad.cli.output import emit_json, errors_reported, info
|
|
10
|
+
from dromad.client import Client
|
|
11
|
+
from dromad.client.errors import DromadError
|
|
12
|
+
from dromad.schemas.audit import AuditReport, AuditResult
|
|
13
|
+
from dromad.schemas.runs import TERMINAL_RUN_STATUSES, Run
|
|
14
|
+
|
|
15
|
+
STATUS_MARK = {"fail": "✗", "error": "!", "pass": "✓", "not_applicable": "–"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _line(label: str, value) -> str:
|
|
19
|
+
return f"{label:<12} {value}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_audit(result: AuditResult, run_id: str) -> str:
|
|
23
|
+
fetch, content, meta, structure = (
|
|
24
|
+
result.fetch,
|
|
25
|
+
result.content,
|
|
26
|
+
result.metadata,
|
|
27
|
+
result.structure,
|
|
28
|
+
)
|
|
29
|
+
lines = [result.page.url]
|
|
30
|
+
if result.page.final_url != result.page.url:
|
|
31
|
+
lines.append(f" → {result.page.final_url}")
|
|
32
|
+
hops = len(fetch.redirect_chain)
|
|
33
|
+
parts = [f"{fetch.status} {fetch.http_status or ''}".strip(), fetch.content_type or "–"]
|
|
34
|
+
parts.append(f"{fetch.elapsed_ms} ms")
|
|
35
|
+
if hops:
|
|
36
|
+
parts.append(f"{hops} redirect{'s' if hops > 1 else ''}")
|
|
37
|
+
if fetch.robots_txt and fetch.robots_txt.allows_page is False:
|
|
38
|
+
parts.append("robots.txt disallows")
|
|
39
|
+
lines += ["", _line("Fetched", " · ".join(parts))]
|
|
40
|
+
if fetch.error:
|
|
41
|
+
lines.append(_line("", fetch.error))
|
|
42
|
+
if meta.title is not None or content.extraction_method:
|
|
43
|
+
lines += [
|
|
44
|
+
_line("Title", meta.title or "—"),
|
|
45
|
+
_line("Description", meta.description or "—"),
|
|
46
|
+
_line("Canonical", meta.canonical or "—"),
|
|
47
|
+
_line("Language", meta.lang or "—"),
|
|
48
|
+
]
|
|
49
|
+
links = content.links
|
|
50
|
+
lines.append(
|
|
51
|
+
_line(
|
|
52
|
+
"Content",
|
|
53
|
+
f"{content.word_count:,} words · {content.code_blocks} code blocks · "
|
|
54
|
+
f"{content.tables} tables · {links.total} links ({links.internal} internal) · "
|
|
55
|
+
f"via {content.extraction_method}",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if content.likely_js_dependent:
|
|
59
|
+
lines.append(
|
|
60
|
+
_line("", "looks like a JavaScript shell (Dromad does not run JavaScript)")
|
|
61
|
+
)
|
|
62
|
+
skips = len(structure.level_skips)
|
|
63
|
+
lines.append(
|
|
64
|
+
_line(
|
|
65
|
+
"Outline",
|
|
66
|
+
# The outline is the main content's; whether the page has an h1
|
|
67
|
+
# at all is the missing_h1 finding, which looks at the whole page.
|
|
68
|
+
f"{len(structure.headings)} headings in the main content · "
|
|
69
|
+
f"{skips} level skip{'s' if skips != 1 else ''}",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if meta.structured_data_types or meta.structured_data_errors:
|
|
73
|
+
types = ", ".join(meta.structured_data_types) or "—"
|
|
74
|
+
errors = (
|
|
75
|
+
f" · {meta.structured_data_errors} unparseable"
|
|
76
|
+
if meta.structured_data_errors
|
|
77
|
+
else ""
|
|
78
|
+
)
|
|
79
|
+
lines.append(_line("Structured", types + errors))
|
|
80
|
+
if meta.dates.published or meta.dates.modified:
|
|
81
|
+
lines.append(
|
|
82
|
+
_line(
|
|
83
|
+
"Dates",
|
|
84
|
+
f"published {meta.dates.published or '—'} · modified {meta.dates.modified or '—'}",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
if meta.llms_txt:
|
|
88
|
+
probes = " · ".join(
|
|
89
|
+
f"{p.url.split('://', 1)[-1]} "
|
|
90
|
+
+ (f"found ({p.links} links)" if p.found else str(p.http_status or "no response"))
|
|
91
|
+
for p in meta.llms_txt
|
|
92
|
+
)
|
|
93
|
+
lines.append(_line("llms.txt", probes))
|
|
94
|
+
|
|
95
|
+
lines += ["", "FINDINGS"]
|
|
96
|
+
width = max(len(f.key) for f in result.findings) if result.findings else 0
|
|
97
|
+
for finding in result.findings:
|
|
98
|
+
mark = STATUS_MARK.get(finding.status, "?")
|
|
99
|
+
head = f" {mark} {finding.key:<{width}} {finding.status:<14} {finding.severity:<6} {finding.evidence_level}"
|
|
100
|
+
lines.append(head.rstrip())
|
|
101
|
+
for item in finding.items:
|
|
102
|
+
lines.append(f" {item.summary}")
|
|
103
|
+
if finding.status in ("not_applicable", "error") and finding.reason:
|
|
104
|
+
lines.append(f" {finding.reason}")
|
|
105
|
+
lines += ["", f"Run {run_id}"]
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_audit_run(run: Run) -> str:
|
|
110
|
+
if isinstance(run.result, AuditResult):
|
|
111
|
+
return render_audit(run.result, run.id)
|
|
112
|
+
if run.error:
|
|
113
|
+
return "\n".join(
|
|
114
|
+
[
|
|
115
|
+
f"{run.input.get('url', '')}",
|
|
116
|
+
f"✗ {run.error.code}",
|
|
117
|
+
run.error.message,
|
|
118
|
+
f"Retryable: {'yes' if run.error.retryable else 'no'}",
|
|
119
|
+
f"Run {run.id}",
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
return f"{run.input.get('url', '')}\n… {run.status}\nRun {run.id}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def wait_for_run(client: Client, run_id: str, *, timeout: float) -> Run:
|
|
126
|
+
deadline = time.monotonic() + timeout
|
|
127
|
+
while True:
|
|
128
|
+
remaining = deadline - time.monotonic()
|
|
129
|
+
run = client.get_run(run_id, wait=max(0, min(30, int(remaining))))
|
|
130
|
+
if run.status in TERMINAL_RUN_STATUSES:
|
|
131
|
+
return run
|
|
132
|
+
if time.monotonic() >= deadline:
|
|
133
|
+
raise DromadError(
|
|
134
|
+
"timeout",
|
|
135
|
+
f"Run {run_id} is still running after {timeout:g}s. It continues; "
|
|
136
|
+
f"check it with `dromad runs show {run_id}`.",
|
|
137
|
+
retryable=True,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def audit(
|
|
142
|
+
url: str = typer.Argument(..., help="The page to audit."),
|
|
143
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for the result (default)."),
|
|
144
|
+
timeout: int = typer.Option(180, "--timeout", help="Seconds to wait."),
|
|
145
|
+
api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
|
|
146
|
+
as_json: bool = typer.Option(False, "--json", help="Print one JSON document to stdout."),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Fetch one page as a crawler would and report its facts and findings.
|
|
149
|
+
|
|
150
|
+
Every finding says how strongly it is backed (evidence_level): an observed
|
|
151
|
+
fact, an established consequence, a suggestive signal, or an experimental
|
|
152
|
+
hypothesis. The result is a run (dr_…) that keeps the full captured page.
|
|
153
|
+
"""
|
|
154
|
+
with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
|
|
155
|
+
run = client.create_audit(url)
|
|
156
|
+
if wait:
|
|
157
|
+
info(f"Auditing {url} · run {run.id}")
|
|
158
|
+
run = wait_for_run(client, run.id, timeout=timeout)
|
|
159
|
+
if as_json:
|
|
160
|
+
if isinstance(run.result, AuditResult):
|
|
161
|
+
emit_json(AuditReport(run_id=run.id, **run.result.model_dump()))
|
|
162
|
+
else:
|
|
163
|
+
emit_json(run)
|
|
164
|
+
else:
|
|
165
|
+
typer.echo(render_audit_run(run))
|
|
166
|
+
if run.status == "failed":
|
|
167
|
+
raise typer.Exit(int(ExitCode.FAILURE))
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""``dromad login``, ``dromad logout``, ``dromad whoami``."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from dromad import config
|
|
12
|
+
from dromad.cli.output import emit_json, errors_reported, info
|
|
13
|
+
from dromad.client import Client
|
|
14
|
+
from dromad.client.errors import ApiError, ConnectionFailed, DromadError
|
|
15
|
+
from dromad.schemas.auth import CLIAuthStatus
|
|
16
|
+
|
|
17
|
+
# Indirection so tests can run the polling loop without waiting.
|
|
18
|
+
_sleep = time.sleep
|
|
19
|
+
_monotonic = time.monotonic
|
|
20
|
+
|
|
21
|
+
JSON_OPTION = typer.Option(False, "--json", help="Print one JSON document to stdout.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_client_name() -> str:
|
|
25
|
+
return f"dromad CLI on {socket.gethostname()}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def login(
|
|
29
|
+
api_url: str | None = typer.Option(
|
|
30
|
+
None, "--api-url", help="Dromad server (default: DROMAD_API_URL, else dromad.dev)."
|
|
31
|
+
),
|
|
32
|
+
name: str | None = typer.Option(
|
|
33
|
+
None, "--name", help="How this device appears under your account's sessions."
|
|
34
|
+
),
|
|
35
|
+
no_browser: bool = typer.Option(
|
|
36
|
+
False, "--no-browser", help="Print the sign-in URL instead of opening it."
|
|
37
|
+
),
|
|
38
|
+
as_json: bool = JSON_OPTION,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Sign in through the browser and store a CLI token for this device."""
|
|
41
|
+
with errors_reported(as_json=as_json):
|
|
42
|
+
settings = config.resolve(api_url)
|
|
43
|
+
with Client(settings.api_url, token=None) as client:
|
|
44
|
+
start = client.cli_auth_start(name or _default_client_name())
|
|
45
|
+
info("To sign in, open this URL in a browser:")
|
|
46
|
+
info(f"\n {start.verification_uri_complete}\n")
|
|
47
|
+
info(f"and check that it shows the code {start.user_code}\n")
|
|
48
|
+
if not no_browser:
|
|
49
|
+
try:
|
|
50
|
+
webbrowser.open(start.verification_uri_complete)
|
|
51
|
+
except webbrowser.Error:
|
|
52
|
+
pass
|
|
53
|
+
info("Waiting for approval…")
|
|
54
|
+
|
|
55
|
+
deadline = _monotonic() + start.expires_in
|
|
56
|
+
while True:
|
|
57
|
+
_sleep(start.interval)
|
|
58
|
+
poll = client.cli_auth_poll(start.device_code)
|
|
59
|
+
if poll.status == CLIAuthStatus.APPROVED and poll.token and poll.token_id:
|
|
60
|
+
break
|
|
61
|
+
if poll.status == CLIAuthStatus.DENIED:
|
|
62
|
+
raise DromadError("access_denied", "Sign-in was denied in the browser.")
|
|
63
|
+
if poll.status != CLIAuthStatus.PENDING or _monotonic() > deadline:
|
|
64
|
+
raise DromadError(
|
|
65
|
+
"expired_token",
|
|
66
|
+
"The sign-in request expired before it was approved. "
|
|
67
|
+
"Run `dromad login` again.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
path = config.save_credentials(
|
|
71
|
+
config.StoredCredentials(
|
|
72
|
+
api_url=settings.api_url,
|
|
73
|
+
token=poll.token,
|
|
74
|
+
token_id=poll.token_id,
|
|
75
|
+
email=poll.email or "",
|
|
76
|
+
created_at=datetime.now(UTC),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
if as_json:
|
|
80
|
+
emit_json({"email": poll.email, "token_id": poll.token_id, "api_url": settings.api_url})
|
|
81
|
+
else:
|
|
82
|
+
info(f"Logged in as {poll.email}.")
|
|
83
|
+
info(f"Credentials saved to {path}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def logout(as_json: bool = JSON_OPTION) -> None:
|
|
87
|
+
"""Revoke this device's CLI token and delete the local credentials."""
|
|
88
|
+
with errors_reported(as_json=as_json):
|
|
89
|
+
stored = config.load_credentials()
|
|
90
|
+
revoked = False
|
|
91
|
+
if stored is not None:
|
|
92
|
+
try:
|
|
93
|
+
with Client(stored.api_url, stored.token) as client:
|
|
94
|
+
client.revoke_cli_token()
|
|
95
|
+
revoked = True
|
|
96
|
+
except ApiError as exc:
|
|
97
|
+
# Already revoked or expired server-side: nothing left to undo.
|
|
98
|
+
if exc.status != 401:
|
|
99
|
+
raise
|
|
100
|
+
except ConnectionFailed:
|
|
101
|
+
info(
|
|
102
|
+
f"Could not reach {stored.api_url} to revoke the token; "
|
|
103
|
+
"removing the local credentials anyway. Revoke it from "
|
|
104
|
+
"your account's sessions page."
|
|
105
|
+
)
|
|
106
|
+
config.delete_credentials()
|
|
107
|
+
env_key_set = bool(os.environ.get(config.ENV_API_KEY))
|
|
108
|
+
if as_json:
|
|
109
|
+
emit_json(
|
|
110
|
+
{
|
|
111
|
+
"logged_out": stored is not None,
|
|
112
|
+
"token_revoked": revoked,
|
|
113
|
+
"api_key_env_set": env_key_set,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
info("Logged out." if stored is not None else "Not logged in.")
|
|
118
|
+
if env_key_set:
|
|
119
|
+
info(f"{config.ENV_API_KEY} is still set; unset it to stop using that key.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def whoami(
|
|
123
|
+
api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
|
|
124
|
+
as_json: bool = JSON_OPTION,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Show which account and credential the CLI is using."""
|
|
127
|
+
with errors_reported(as_json=as_json):
|
|
128
|
+
settings = config.resolve(api_url)
|
|
129
|
+
with Client.from_settings(settings) as client:
|
|
130
|
+
me = client.me()
|
|
131
|
+
if as_json:
|
|
132
|
+
emit_json(me)
|
|
133
|
+
return
|
|
134
|
+
typer.echo(me.email)
|
|
135
|
+
typer.echo(f"{me.credential.kind} {me.credential.id} ({me.credential.name})")
|
|
136
|
+
typer.echo(f"source {settings.source} · {settings.api_url}")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""``dromad citations`` — what a set of prompt runs cited, counted."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from dromad import config
|
|
6
|
+
from dromad.cli.output import emit_json, errors_reported, table
|
|
7
|
+
from dromad.cli.render import short_url
|
|
8
|
+
from dromad.client import Client
|
|
9
|
+
from dromad.schemas.analyses import CitationAnalysis
|
|
10
|
+
from dromad.schemas.ids import IdKind, is_id
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pct(numerator: int, denominator: int) -> str:
|
|
14
|
+
return f"{round(100 * numerator / denominator)}%" if denominator else "–"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _n(count: int, noun: str) -> str:
|
|
18
|
+
return f"{count} {noun}{'' if count == 1 else 's'}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_citations(analysis: CitationAnalysis, *, top: int = 10) -> str:
|
|
22
|
+
t = analysis.totals
|
|
23
|
+
lines = [
|
|
24
|
+
f"{t.runs} prompt runs · {t.prompts} prompts · {t.engines} engines",
|
|
25
|
+
f"{t.citations} citations · {t.unique_cited_urls} unique URLs · "
|
|
26
|
+
f"{t.unique_cited_domains} unique domains",
|
|
27
|
+
]
|
|
28
|
+
unknowns = []
|
|
29
|
+
if t.retrieved_not_cited:
|
|
30
|
+
unknowns.append(f"{t.retrieved_not_cited} sources seen but not cited")
|
|
31
|
+
if t.citation_status_unknown:
|
|
32
|
+
unknowns.append(f"{t.citation_status_unknown} with citation status unknown")
|
|
33
|
+
if t.unresolved_sources:
|
|
34
|
+
unknowns.append(f"{t.unresolved_sources} unresolved redirects")
|
|
35
|
+
if unknowns:
|
|
36
|
+
lines.append(" · ".join(unknowns))
|
|
37
|
+
|
|
38
|
+
if analysis.domains:
|
|
39
|
+
rows = [
|
|
40
|
+
[d.domain, d.citations, d.pages, d.runs, d.prompts, ", ".join(d.engines)]
|
|
41
|
+
for d in analysis.domains[:top]
|
|
42
|
+
]
|
|
43
|
+
lines += ["", table(rows, ["DOMAIN", "CITATIONS", "PAGES", "RUNS", "PROMPTS", "ENGINES"])]
|
|
44
|
+
|
|
45
|
+
if len(analysis.engines) > 1:
|
|
46
|
+
rows = [
|
|
47
|
+
[e.engine, e.runs, e.runs_with_citations, e.citations, e.unique_cited_domains]
|
|
48
|
+
for e in analysis.engines
|
|
49
|
+
]
|
|
50
|
+
lines += ["", table(rows, ["ENGINE", "RUNS", "WITH CITATIONS", "CITATIONS", "DOMAINS"])]
|
|
51
|
+
|
|
52
|
+
if analysis.pages:
|
|
53
|
+
lines += ["", "TOP PAGES"]
|
|
54
|
+
for n, page in enumerate(analysis.pages[:top], 1):
|
|
55
|
+
lines.append(
|
|
56
|
+
f" {n:>2} {short_url(page.url, 70)} · {_n(page.citations, 'citation')} · "
|
|
57
|
+
f"{_n(page.runs, 'run')} · {', '.join(page.engines)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
overlap = analysis.overlap
|
|
61
|
+
if overlap.engines > 1:
|
|
62
|
+
by = overlap.domains_by_engine_count
|
|
63
|
+
parts = [f"by {n}: {by[str(n)]}" for n in range(overlap.engines, 0, -1)]
|
|
64
|
+
lines += ["", "CROSS-ENGINE OVERLAP", " cited domains " + " · ".join(parts)]
|
|
65
|
+
if overlap.domains_cited_by_every_engine:
|
|
66
|
+
lines.append(
|
|
67
|
+
" cited by every engine: " + ", ".join(overlap.domains_cited_by_every_engine)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if analysis.textual_mentions:
|
|
71
|
+
lines += ["", "TEXTUAL MENTIONS (naive whole-word match in answer text)"]
|
|
72
|
+
for m in analysis.textual_mentions:
|
|
73
|
+
lines.append(
|
|
74
|
+
f" {m.term} {m.runs_mentioning} of {m.runs} runs · "
|
|
75
|
+
f"{m.prompts_mentioning} of {m.prompts} prompts · {m.occurrences} occurrences"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for c in analysis.domain_coverage:
|
|
79
|
+
engines = " · ".join(f"{e.engine} {e.cited_runs}/{e.runs}" for e in c.by_engine)
|
|
80
|
+
lines += [
|
|
81
|
+
"",
|
|
82
|
+
f"DOMAIN COVERAGE {c.domain}",
|
|
83
|
+
f" cited in {c.cited_runs} of {c.runs} runs ({_pct(c.cited_runs, c.runs)}) · "
|
|
84
|
+
f"{c.cited_prompts} of {c.prompts} prompts · {c.citations} citations",
|
|
85
|
+
f" by engine: {engines}",
|
|
86
|
+
]
|
|
87
|
+
if c.unknown_runs:
|
|
88
|
+
lines.append(
|
|
89
|
+
f" {_n(c.unknown_runs, 'run')} did not expose citations; the rate is a floor"
|
|
90
|
+
)
|
|
91
|
+
if c.pages:
|
|
92
|
+
lines.append(f" pages cited: {', '.join(short_url(p, 60) for p in c.pages[:top])}")
|
|
93
|
+
if c.prompts_seen_not_cited:
|
|
94
|
+
lines.append(f" seen but not cited on {_n(len(c.prompts_seen_not_cited), 'prompt')}")
|
|
95
|
+
if c.prompts_absent:
|
|
96
|
+
lines.append(
|
|
97
|
+
f" absent from {_n(len(c.prompts_absent), 'prompt')}, e.g. “{c.prompts_absent[0]}”"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if analysis.excluded_runs:
|
|
101
|
+
lines += ["", f"Excluded {len(analysis.excluded_runs)} runs:"]
|
|
102
|
+
lines += [f" {e.run_id} {e.reason}" for e in analysis.excluded_runs]
|
|
103
|
+
lines += ["", f"Analysis {analysis.id} · {analysis.method}"]
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def citations(
|
|
108
|
+
ids: list[str] = typer.Argument(..., help="Run IDs (dr_…) and/or batch IDs (rq_…)."),
|
|
109
|
+
brand: list[str] = typer.Option(
|
|
110
|
+
[], "--brand", "-b", help="A term to count in answers (repeatable). Naive word match."
|
|
111
|
+
),
|
|
112
|
+
domain: list[str] = typer.Option(
|
|
113
|
+
[], "--domain", "-d", help="A domain to report coverage for (repeatable)."
|
|
114
|
+
),
|
|
115
|
+
top: int = typer.Option(10, "--top", min=1, help="Rows per table."),
|
|
116
|
+
api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
|
|
117
|
+
as_json: bool = typer.Option(False, "--json", help="Print one JSON document to stdout."),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Count what a set of prompt runs cited: domains, pages, engines, overlap.
|
|
120
|
+
|
|
121
|
+
Only citations an engine showed are counted; sources whose citation
|
|
122
|
+
status was not exposed are reported as unknown. The result is a durable
|
|
123
|
+
analysis (ca_…).
|
|
124
|
+
"""
|
|
125
|
+
bad = [i for i in ids if not is_id(i, IdKind.RUN, IdKind.REQUEST_BATCH)]
|
|
126
|
+
if bad:
|
|
127
|
+
raise typer.BadParameter(f"not a run or batch ID: {', '.join(bad)}", param_hint="IDS")
|
|
128
|
+
with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
|
|
129
|
+
analysis = client.create_citation_analysis(ids, brands=brand, domains=domain)
|
|
130
|
+
if as_json:
|
|
131
|
+
emit_json(analysis)
|
|
132
|
+
else:
|
|
133
|
+
typer.echo(render_citations(analysis, top=top))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Process exit codes. Stable: scripts and agents branch on them.
|
|
2
|
+
|
|
3
|
+
0 the command did what it was asked
|
|
4
|
+
1 the operation failed (the server refused it, an engine failed, no connection)
|
|
5
|
+
2 the command could not be attempted (bad arguments, not logged in, bad config)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExitCode(IntEnum):
|
|
12
|
+
OK = 0
|
|
13
|
+
FAILURE = 1
|
|
14
|
+
USAGE = 2
|