codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Whether each provider is installed and signed in, and how, before any paid work (design section 15.2).
|
|
2
|
+
|
|
3
|
+
Claude Code is asked with `claude auth status --json` and Codex with `codex login status`, in the same allowlisted
|
|
4
|
+
environment the calls use; only whether the program is signed in, and how, is kept, never an account's details. With
|
|
5
|
+
`auth = "subscription"`, a sign-in that bills an API account instead is refused. The local provider's endpoint is asked
|
|
6
|
+
for its models, which are shown so a stranger answering on the port is noticed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from codetrail.assistant import AssistantError, runner
|
|
21
|
+
from codetrail.assistant.environment import child_environment, resolve_program
|
|
22
|
+
from codetrail.config import GlobalConfig, TargetConfig, model_choice
|
|
23
|
+
|
|
24
|
+
CHECK_TIMEOUT_SECONDS = 30
|
|
25
|
+
INSTALL = {
|
|
26
|
+
"claude_code": "Install Claude Code (https://code.claude.com/docs/en/setup), run `claude` once and sign in with "
|
|
27
|
+
"/login using your Claude subscription.",
|
|
28
|
+
"codex": "Install Codex (`npm install -g @openai/codex`), then run `codex login` and choose Sign in with ChatGPT.",
|
|
29
|
+
}
|
|
30
|
+
CODEX_WARNING = (
|
|
31
|
+
"Codex reads outside the repository's allowed files (design section 15.5). Its isolation from your Codex "
|
|
32
|
+
"settings is tested against a stand-in, and checked live only where Codex is installed (`just test-live`)."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProviderStatus:
|
|
38
|
+
provider: str
|
|
39
|
+
ready: bool
|
|
40
|
+
installed: bool = False
|
|
41
|
+
signed_in: bool = False
|
|
42
|
+
program: str = ""
|
|
43
|
+
method: str = ""
|
|
44
|
+
fix: str = ""
|
|
45
|
+
warning: str = ""
|
|
46
|
+
models: tuple[str, ...] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def provider_status(
|
|
50
|
+
provider: str, settings: GlobalConfig, environ: Mapping[str, str] = os.environ, client: httpx.Client | None = None
|
|
51
|
+
) -> ProviderStatus:
|
|
52
|
+
if provider == "claude_code":
|
|
53
|
+
return _claude_code(settings, environ)
|
|
54
|
+
if provider == "codex":
|
|
55
|
+
return _codex(settings, environ)
|
|
56
|
+
return _local(settings, client)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def require_ready(
|
|
60
|
+
target: TargetConfig,
|
|
61
|
+
settings: GlobalConfig,
|
|
62
|
+
environ: Mapping[str, str] = os.environ,
|
|
63
|
+
kinds: Sequence[str] = ("plan", "write", "digest", "answer", "grade"),
|
|
64
|
+
) -> list[ProviderStatus]:
|
|
65
|
+
"""The status of each provider the given kinds of call use; raises with the fix when one isn't ready."""
|
|
66
|
+
providers = list(dict.fromkeys(model_choice(getattr(target.models, kind))[0] for kind in kinds))
|
|
67
|
+
statuses = [provider_status(provider, settings, environ) for provider in providers]
|
|
68
|
+
for status in statuses:
|
|
69
|
+
if not status.ready:
|
|
70
|
+
raise AssistantError(f"{status.provider} isn't ready. {status.fix}")
|
|
71
|
+
return statuses
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _claude_code(settings: GlobalConfig, environ: Mapping[str, str]) -> ProviderStatus:
|
|
75
|
+
config = settings.providers.claude_code
|
|
76
|
+
environment = child_environment("claude_code", config.auth, environ)
|
|
77
|
+
try:
|
|
78
|
+
program = resolve_program(config.command, environment.get("PATH", ""))
|
|
79
|
+
except AssistantError:
|
|
80
|
+
return ProviderStatus("claude_code", False, fix=INSTALL["claude_code"])
|
|
81
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
82
|
+
try:
|
|
83
|
+
_code, output = runner.run_command(
|
|
84
|
+
[program, "auth", "status", "--json"], Path(folder), environment, CHECK_TIMEOUT_SECONDS
|
|
85
|
+
)
|
|
86
|
+
found = json.loads(output)
|
|
87
|
+
except AssistantError, ValueError:
|
|
88
|
+
return ProviderStatus(
|
|
89
|
+
"claude_code",
|
|
90
|
+
False,
|
|
91
|
+
True,
|
|
92
|
+
program=program,
|
|
93
|
+
fix="`claude auth status` didn't answer; run `claude` once to finish its setup.",
|
|
94
|
+
)
|
|
95
|
+
found = found if isinstance(found, dict) else {}
|
|
96
|
+
signed_in = found.get("loggedIn") is True
|
|
97
|
+
subscription = found.get("authMethod") == "claude.ai"
|
|
98
|
+
plan = str(found.get("subscriptionType") or "")
|
|
99
|
+
method = (
|
|
100
|
+
f"Claude subscription ({plan})"
|
|
101
|
+
if subscription and plan
|
|
102
|
+
else ("Claude subscription" if subscription else f"{found.get('authMethod', 'unknown')} sign-in")
|
|
103
|
+
)
|
|
104
|
+
if not signed_in:
|
|
105
|
+
return ProviderStatus(
|
|
106
|
+
"claude_code",
|
|
107
|
+
False,
|
|
108
|
+
True,
|
|
109
|
+
False,
|
|
110
|
+
program,
|
|
111
|
+
fix="Run `claude` and sign in with /login using your Claude subscription.",
|
|
112
|
+
)
|
|
113
|
+
if config.auth == "subscription" and not subscription:
|
|
114
|
+
return ProviderStatus(
|
|
115
|
+
"claude_code",
|
|
116
|
+
False,
|
|
117
|
+
True,
|
|
118
|
+
True,
|
|
119
|
+
program,
|
|
120
|
+
method,
|
|
121
|
+
fix="Claude Code isn't signed in with a Claude subscription: run `claude` and /login "
|
|
122
|
+
'with your Claude account, or set [providers.claude_code] auth = "api_key".',
|
|
123
|
+
)
|
|
124
|
+
return ProviderStatus("claude_code", True, True, True, program, method)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _codex(settings: GlobalConfig, environ: Mapping[str, str]) -> ProviderStatus:
|
|
128
|
+
config = settings.providers.codex
|
|
129
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
130
|
+
home = Path(folder) / "home"
|
|
131
|
+
home.mkdir()
|
|
132
|
+
environment = child_environment("codex", config.auth, environ, empty_home=home)
|
|
133
|
+
try:
|
|
134
|
+
program = resolve_program(config.command, environment.get("PATH", ""))
|
|
135
|
+
except AssistantError:
|
|
136
|
+
return ProviderStatus("codex", False, fix=INSTALL["codex"], warning=CODEX_WARNING)
|
|
137
|
+
try:
|
|
138
|
+
code, output = runner.run_command(
|
|
139
|
+
[program, "login", "status"], Path(folder), environment, CHECK_TIMEOUT_SECONDS
|
|
140
|
+
)
|
|
141
|
+
except AssistantError:
|
|
142
|
+
code, output = 1, ""
|
|
143
|
+
signed_in = code == 0 and "logged in" in output.lower() and "not logged in" not in output.lower()
|
|
144
|
+
subscription = "chatgpt" in output.lower()
|
|
145
|
+
method = "ChatGPT subscription" if subscription else "API key"
|
|
146
|
+
if not signed_in:
|
|
147
|
+
return ProviderStatus(
|
|
148
|
+
"codex",
|
|
149
|
+
False,
|
|
150
|
+
True,
|
|
151
|
+
False,
|
|
152
|
+
program,
|
|
153
|
+
fix="Run `codex login` and choose Sign in with ChatGPT.",
|
|
154
|
+
warning=CODEX_WARNING,
|
|
155
|
+
)
|
|
156
|
+
if config.auth == "subscription" and not subscription:
|
|
157
|
+
return ProviderStatus(
|
|
158
|
+
"codex",
|
|
159
|
+
False,
|
|
160
|
+
True,
|
|
161
|
+
True,
|
|
162
|
+
program,
|
|
163
|
+
method,
|
|
164
|
+
fix="Codex is signed in with an API key: run `codex login` and choose Sign in with "
|
|
165
|
+
'ChatGPT, or set [providers.codex] auth = "api_key".',
|
|
166
|
+
warning=CODEX_WARNING,
|
|
167
|
+
)
|
|
168
|
+
return ProviderStatus("codex", True, True, True, program, method, warning=CODEX_WARNING)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _local(settings: GlobalConfig, client: httpx.Client | None) -> ProviderStatus:
|
|
172
|
+
config = settings.providers.local
|
|
173
|
+
fix = f"Start Ollama (`ollama serve`) or LM Studio's server, or set [providers.local] base_url ({config.base_url})."
|
|
174
|
+
http = client or httpx.Client(trust_env=False, follow_redirects=False, timeout=10)
|
|
175
|
+
try:
|
|
176
|
+
response = http.get(config.base_url.rstrip("/") + "/models")
|
|
177
|
+
listed = response.json().get("data", []) if response.status_code == 200 else None
|
|
178
|
+
except httpx.HTTPError, ValueError, AttributeError:
|
|
179
|
+
listed = None
|
|
180
|
+
finally:
|
|
181
|
+
if client is None:
|
|
182
|
+
http.close()
|
|
183
|
+
if not isinstance(listed, list):
|
|
184
|
+
return ProviderStatus("local", False, fix=fix, program=config.base_url)
|
|
185
|
+
models = tuple(str(item.get("id")) for item in listed if isinstance(item, dict) and item.get("id"))
|
|
186
|
+
return ProviderStatus("local", True, True, True, config.base_url, "no sign-in needed", models=models)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Records what each assistant call used, and keeps the latest plan usage readings (design section 15.3).
|
|
2
|
+
|
|
3
|
+
A call's cost is the provider's own figure when it gives one, otherwise its tokens at the configured prices; a local
|
|
4
|
+
model costs nothing; a model with no configured price has tokens only. These records are the history the estimates
|
|
5
|
+
are made from (section 15.4).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
from codetrail.assistant import Usage
|
|
16
|
+
from codetrail.config import Price
|
|
17
|
+
|
|
18
|
+
PER_MILLION = 1_000_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def priced(usage: Usage, prices: Mapping[str, Price]) -> float | None:
|
|
22
|
+
"""The usage at the configured prices; zero for local models; None for a model without a price."""
|
|
23
|
+
if usage.provider == "local":
|
|
24
|
+
return 0.0
|
|
25
|
+
price = prices.get(usage.model)
|
|
26
|
+
if price is None:
|
|
27
|
+
return None
|
|
28
|
+
cached = price.cached_input if price.cached_input is not None else price.input
|
|
29
|
+
return (
|
|
30
|
+
usage.input_tokens * price.input + usage.cached_input_tokens * cached + usage.output_tokens * price.output
|
|
31
|
+
) / PER_MILLION
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UsageLog:
|
|
35
|
+
def __init__(self, connection: sqlite3.Connection, prices: Mapping[str, Price]) -> None:
|
|
36
|
+
self.connection = connection
|
|
37
|
+
self.prices = prices
|
|
38
|
+
|
|
39
|
+
def record(self, kind: str, usage: Usage) -> float:
|
|
40
|
+
"""Records the call and returns its cost in dollars (zero when unknown) for the update's budget."""
|
|
41
|
+
if not usage.provider:
|
|
42
|
+
return 0.0
|
|
43
|
+
cost = usage.cost_usd if usage.cost_usd is not None else priced(usage, self.prices)
|
|
44
|
+
now = datetime.now(UTC).isoformat(timespec="seconds")
|
|
45
|
+
self.connection.execute("BEGIN") # the call and its plan readings are saved together or not at all
|
|
46
|
+
try:
|
|
47
|
+
self.connection.execute(
|
|
48
|
+
"INSERT INTO assistant_calls (called_at, kind, provider, model, input_tokens, cached_input_tokens,"
|
|
49
|
+
" output_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
50
|
+
(now, kind, usage.provider, usage.model, usage.input_tokens, usage.cached_input_tokens,
|
|
51
|
+
usage.output_tokens, cost),
|
|
52
|
+
) # fmt: skip
|
|
53
|
+
for window in usage.plan_windows:
|
|
54
|
+
self.connection.execute(
|
|
55
|
+
"INSERT INTO plan_usage (provider, window, utilization, resets_at, observed_at)"
|
|
56
|
+
" VALUES (?, ?, ?, ?, ?) ON CONFLICT (provider, window) DO UPDATE SET"
|
|
57
|
+
" utilization = excluded.utilization, resets_at = excluded.resets_at,"
|
|
58
|
+
" observed_at = excluded.observed_at",
|
|
59
|
+
(usage.provider, window.window, window.utilization, window.resets_at, now),
|
|
60
|
+
)
|
|
61
|
+
self.connection.execute("COMMIT")
|
|
62
|
+
except BaseException:
|
|
63
|
+
if self.connection.in_transaction: # SQLite ends it itself after some errors
|
|
64
|
+
self.connection.execute("ROLLBACK")
|
|
65
|
+
raise
|
|
66
|
+
return cost or 0.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class UpdateUsage:
|
|
71
|
+
tokens: int
|
|
72
|
+
cost_usd: float # what was reported or priced; calls with no price count as zero
|
|
73
|
+
calls: int
|
|
74
|
+
finished_at: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def last_update_usage(connection: sqlite3.Connection) -> UpdateUsage | None:
|
|
78
|
+
"""What the latest update that wrote the guide used: its plan, write and digest calls (design section 16.1).
|
|
79
|
+
|
|
80
|
+
Those calls follow the update's own snapshot, so they run from the last snapshot taken before the latest of them.
|
|
81
|
+
"""
|
|
82
|
+
finished = connection.execute(
|
|
83
|
+
"SELECT MAX(called_at) FROM assistant_calls WHERE kind IN ('plan', 'write', 'digest')"
|
|
84
|
+
).fetchone()[0]
|
|
85
|
+
if finished is None:
|
|
86
|
+
return None
|
|
87
|
+
start = connection.execute("SELECT MAX(taken_at) FROM snapshots WHERE taken_at <= ?", (finished,)).fetchone()[0]
|
|
88
|
+
row = connection.execute(
|
|
89
|
+
"SELECT COALESCE(SUM(input_tokens + cached_input_tokens + output_tokens), 0), COALESCE(SUM(cost_usd), 0),"
|
|
90
|
+
" COUNT(*) FROM assistant_calls WHERE kind IN ('plan', 'write', 'digest') AND called_at BETWEEN ? AND ?",
|
|
91
|
+
(start or "", finished),
|
|
92
|
+
).fetchone()
|
|
93
|
+
return UpdateUsage(int(row[0]), round(float(row[1]), 4), int(row[2]), str(finished))
|
codetrail/bridge.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""The bridge: questions from the page, answered read-only by the assistant, saved to the guide on request (7.3).
|
|
2
|
+
|
|
3
|
+
`POST /bridge/questions` streams newline-delimited JSON: `text` events while the assistant writes, then `done` with
|
|
4
|
+
the answer's id and its rendered HTML, or `error`. The whole answer is scanned for secrets before `done`; one that
|
|
5
|
+
holds something gitleaks flags ends with `error` and isn't kept (design section 15.5). Its usage is recorded.
|
|
6
|
+
Answers stay in memory for the session until saved, at most `max_session_answers` of them, and `GET /bridge/answers`
|
|
7
|
+
lists them for the page's Ask panel. One question runs at a time per session; closing the page
|
|
8
|
+
cancels it. Every route sits behind the security middleware.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
from fastapi import APIRouter
|
|
24
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
25
|
+
from markupsafe import escape
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
27
|
+
|
|
28
|
+
from codetrail.assistant import AnswerChunk, Assistant, AssistantError, QuestionRequest, Usage
|
|
29
|
+
from codetrail.assistant.usage import UsageLog
|
|
30
|
+
from codetrail.config import Paths, Price, ToolsSettings
|
|
31
|
+
from codetrail.database import connect
|
|
32
|
+
from codetrail.errors import CodetrailError
|
|
33
|
+
from codetrail.facts.store import FactStore
|
|
34
|
+
from codetrail.generate.validate import MARKER, ValidationContext, parse_rationale, validate_page
|
|
35
|
+
from codetrail.guide import PAGE_ID, GuideRepository, Page
|
|
36
|
+
from codetrail.lock import TargetBusy, target_lock
|
|
37
|
+
from codetrail.repo.secrets import SecretScanner
|
|
38
|
+
from codetrail.repo.source import SourceManifest
|
|
39
|
+
from codetrail.web.diagrams import available_diagrams
|
|
40
|
+
from codetrail.web.i18n import Language
|
|
41
|
+
from codetrail.web.render import render_body
|
|
42
|
+
|
|
43
|
+
PAGE_CONTEXT_CHARACTERS = 12_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Question(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
question: str = Field(min_length=1, max_length=20_000)
|
|
49
|
+
page_id: str | None = Field(default=None, max_length=200)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Answer:
|
|
54
|
+
question: str
|
|
55
|
+
language: str
|
|
56
|
+
body: str
|
|
57
|
+
files_read: list[str]
|
|
58
|
+
commit: str
|
|
59
|
+
asked_at: str
|
|
60
|
+
cost_usd: float = 0.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BridgeState:
|
|
65
|
+
answers: dict[str, Answer] = field(default_factory=dict)
|
|
66
|
+
answering_since: float | None = None # claimed in the request itself, so simultaneous questions are refused
|
|
67
|
+
holder: str | None = None
|
|
68
|
+
abandon_after_seconds: float = 3600.0 # a slot held this long is treated as abandoned
|
|
69
|
+
|
|
70
|
+
def claim(self) -> str | None:
|
|
71
|
+
"""Claims the one question slot and returns its token; a slot held well past a call's limit is abandoned."""
|
|
72
|
+
now = time.monotonic()
|
|
73
|
+
if self.answering_since is not None and now - self.answering_since < self.abandon_after_seconds:
|
|
74
|
+
return None
|
|
75
|
+
self.answering_since, self.holder = now, uuid.uuid4().hex
|
|
76
|
+
return self.holder
|
|
77
|
+
|
|
78
|
+
def release(self, token: str) -> None:
|
|
79
|
+
"""Releases the slot only for the claim that still holds it."""
|
|
80
|
+
if self.holder == token:
|
|
81
|
+
self.answering_since, self.holder = None, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def bridge_router(
|
|
85
|
+
paths: Paths,
|
|
86
|
+
name: str,
|
|
87
|
+
assistant_for: Callable[[], Assistant],
|
|
88
|
+
language_of: Callable[[], Language],
|
|
89
|
+
max_question_chars: int,
|
|
90
|
+
max_nodes: int,
|
|
91
|
+
tools: ToolsSettings,
|
|
92
|
+
prices: Mapping[str, Price],
|
|
93
|
+
call_timeout_seconds: float,
|
|
94
|
+
max_session_answers: int = 20,
|
|
95
|
+
) -> APIRouter:
|
|
96
|
+
router = APIRouter()
|
|
97
|
+
state = BridgeState(abandon_after_seconds=call_timeout_seconds + 60)
|
|
98
|
+
scanner = SecretScanner(tools)
|
|
99
|
+
data = paths.target_data(name)
|
|
100
|
+
guide = GuideRepository(data / "guide")
|
|
101
|
+
|
|
102
|
+
@router.post("/bridge/questions")
|
|
103
|
+
async def ask(question: Question) -> Response:
|
|
104
|
+
language = language_of()
|
|
105
|
+
_ = language.translations.gettext
|
|
106
|
+
if len(question.question) > max_question_chars:
|
|
107
|
+
limit = _("Questions are limited to %(count)s characters.") % {"count": max_question_chars}
|
|
108
|
+
return JSONResponse({"error": limit}, 413)
|
|
109
|
+
manifest = SourceManifest.load(data / "source.json")
|
|
110
|
+
if manifest is None:
|
|
111
|
+
return JSONResponse({"error": _("Run codetrail update %(target)s first.") % {"target": name}}, 409)
|
|
112
|
+
if question.page_id and not PAGE_ID.fullmatch(question.page_id):
|
|
113
|
+
return JSONResponse({"error": _("That page doesn't exist.")}, 404)
|
|
114
|
+
page = guide.read_page(question.page_id) if question.page_id else None
|
|
115
|
+
if question.page_id and page is None:
|
|
116
|
+
return JSONResponse({"error": _("That page doesn't exist.")}, 404)
|
|
117
|
+
diagrams = _available_diagrams() # before the claim: nothing between claim and stream may fail
|
|
118
|
+
token = state.claim() # no await before this point, so two requests can't both pass
|
|
119
|
+
if token is None:
|
|
120
|
+
return JSONResponse({"error": _("Another question is still being answered.")}, 429)
|
|
121
|
+
request = QuestionRequest(
|
|
122
|
+
target=name,
|
|
123
|
+
question=question.question,
|
|
124
|
+
language=language.code,
|
|
125
|
+
page_title=page.title if page else "",
|
|
126
|
+
page_body=page.body[:PAGE_CONTEXT_CHARACTERS] if page else "",
|
|
127
|
+
page_facts=[str(fact.get("id")) for fact in (page.meta.get("facts") or [])] if page else [],
|
|
128
|
+
diagrams=diagrams,
|
|
129
|
+
)
|
|
130
|
+
return StreamingResponse(_stream(request, manifest.commit, token), media_type="application/x-ndjson")
|
|
131
|
+
|
|
132
|
+
async def _stream(request: QuestionRequest, commit: str, token: str) -> AsyncIterator[bytes]:
|
|
133
|
+
try:
|
|
134
|
+
_ = language_of().translations.gettext # inside the try: the claim is released whatever fails
|
|
135
|
+
parts: list[str] = []
|
|
136
|
+
try:
|
|
137
|
+
async for chunk in assistant_for().answer(request):
|
|
138
|
+
if chunk.text:
|
|
139
|
+
parts.append(chunk.text)
|
|
140
|
+
if not chunk.done: # streamed text; a buffered answer arrives with done, after the scan
|
|
141
|
+
yield _event({"type": "text", "text": chunk.text})
|
|
142
|
+
if chunk.done:
|
|
143
|
+
findings = await anyio.to_thread.run_sync(scanner.scan_text, "".join(parts))
|
|
144
|
+
cost = _record(chunk)
|
|
145
|
+
if findings:
|
|
146
|
+
yield _event(
|
|
147
|
+
{
|
|
148
|
+
"type": "error",
|
|
149
|
+
"message": _(
|
|
150
|
+
"The answer was withheld: it contains something that looks like a secret "
|
|
151
|
+
"(%(rule)s)."
|
|
152
|
+
)
|
|
153
|
+
% {"rule": findings[0].rule},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
answer = Answer(request.question, request.language, "".join(parts), chunk.files_read, commit,
|
|
158
|
+
datetime.now(UTC).isoformat(timespec="seconds"), chunk.cost_usd) # fmt: skip
|
|
159
|
+
answer_id = uuid.uuid4().hex
|
|
160
|
+
state.answers[answer_id] = answer
|
|
161
|
+
while len(state.answers) > max_session_answers: # the oldest unsaved answer goes first
|
|
162
|
+
del state.answers[next(iter(state.answers))]
|
|
163
|
+
used = {"tokens": _tokens(chunk.usage), "cost_usd": round(cost, 4)}
|
|
164
|
+
yield _event(
|
|
165
|
+
{"type": "done", "answer_id": answer_id, "html": _html(answer.body), "usage": used}
|
|
166
|
+
)
|
|
167
|
+
except AssistantError as error:
|
|
168
|
+
yield _event({"type": "error", "message": str(error)})
|
|
169
|
+
except Exception as error: # details stay out of the page
|
|
170
|
+
failed = _("The answer failed (%(error)s).") % {"error": type(error).__name__}
|
|
171
|
+
yield _event({"type": "error", "message": failed})
|
|
172
|
+
finally:
|
|
173
|
+
state.release(token)
|
|
174
|
+
|
|
175
|
+
def _record(chunk: AnswerChunk) -> float:
|
|
176
|
+
connection = connect(data / "codetrail.db")
|
|
177
|
+
try:
|
|
178
|
+
return UsageLog(connection, prices).record("answer", chunk.usage) or chunk.cost_usd
|
|
179
|
+
finally:
|
|
180
|
+
connection.close()
|
|
181
|
+
|
|
182
|
+
def _available_diagrams() -> list[str]:
|
|
183
|
+
"""The diagrams an answer may place; none if they can't be read, since the answer works without them."""
|
|
184
|
+
try:
|
|
185
|
+
connection = connect(data / "codetrail.db")
|
|
186
|
+
try:
|
|
187
|
+
return available_diagrams(FactStore(connection))
|
|
188
|
+
finally:
|
|
189
|
+
connection.close()
|
|
190
|
+
except Exception: # an optional extra: a locked or newer database mustn't stop the question
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
def _html(body: str) -> str:
|
|
194
|
+
connection = connect(data / "codetrail.db")
|
|
195
|
+
try:
|
|
196
|
+
segments = render_body(body, FactStore(connection), max_nodes)
|
|
197
|
+
finally:
|
|
198
|
+
connection.close()
|
|
199
|
+
parts = []
|
|
200
|
+
for segment in segments:
|
|
201
|
+
if segment.kind == "diagram" and segment.diagram is not None: # drawn by the page's script, from facts
|
|
202
|
+
diagram = segment.diagram
|
|
203
|
+
alt = escape(", ".join(node.label for node in diagram.nodes))
|
|
204
|
+
parts.append(f'<figure class="diagram-block" lang="en" dir="ltr" data-alt="{alt}">'
|
|
205
|
+
f'<pre class="diagram">{escape(diagram.d2)}</pre></figure>') # fmt: skip
|
|
206
|
+
else:
|
|
207
|
+
parts.append(str(segment.html))
|
|
208
|
+
return "".join(parts)
|
|
209
|
+
|
|
210
|
+
@router.get("/bridge/answers")
|
|
211
|
+
def session_answers() -> Response:
|
|
212
|
+
"""This session's unsaved answers, oldest first, rendered and sanitized, for the Ask panel (design 16.4)."""
|
|
213
|
+
listed = [
|
|
214
|
+
{"id": answer_id, "question": answer.question, "html": _html(answer.body), "asked_at": answer.asked_at}
|
|
215
|
+
for answer_id, answer in list(state.answers.items())
|
|
216
|
+
]
|
|
217
|
+
return JSONResponse({"answers": listed}, headers={"Cache-Control": "no-store"})
|
|
218
|
+
|
|
219
|
+
@router.post("/bridge/answers/{answer_id}/save")
|
|
220
|
+
def save(answer_id: str) -> Response:
|
|
221
|
+
_ = language_of().translations.gettext
|
|
222
|
+
answer = state.answers.get(answer_id)
|
|
223
|
+
if answer is None:
|
|
224
|
+
return JSONResponse({"error": _("That answer is gone; ask again.")}, 404)
|
|
225
|
+
try:
|
|
226
|
+
with target_lock(paths, name):
|
|
227
|
+
page_id = _save(answer, data, guide, tools)
|
|
228
|
+
except TargetBusy:
|
|
229
|
+
return JSONResponse({"error": _("An update is running; save again when it finishes.")}, 409)
|
|
230
|
+
except CodetrailError as error:
|
|
231
|
+
return JSONResponse({"error": str(error)}, 409)
|
|
232
|
+
del state.answers[answer_id]
|
|
233
|
+
return JSONResponse({"page_id": page_id})
|
|
234
|
+
|
|
235
|
+
return router
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _tokens(usage: Usage) -> int:
|
|
239
|
+
return usage.input_tokens + usage.cached_input_tokens + usage.output_tokens
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _save(answer: Answer, data: Any, guide: GuideRepository, tools: ToolsSettings) -> str:
|
|
243
|
+
guide.ensure()
|
|
244
|
+
manifest = SourceManifest.load(data / "source.json")
|
|
245
|
+
if manifest is None:
|
|
246
|
+
raise AssistantError("The sources are missing; run an update.")
|
|
247
|
+
connection = connect(data / "codetrail.db")
|
|
248
|
+
try:
|
|
249
|
+
store = FactStore(connection)
|
|
250
|
+
context = ValidationContext(data / "source", manifest, store, data / "mirror.git", SecretScanner(tools))
|
|
251
|
+
body = demote_unverified(answer.body, context)
|
|
252
|
+
finally:
|
|
253
|
+
connection.close()
|
|
254
|
+
slug = re.sub(r"[^a-z0-9]+", "-", answer.question.lower()).strip("-")[:50].strip("-") or "answer"
|
|
255
|
+
page_id = f"answers/{datetime.now(UTC).strftime('%Y-%m-%d')}-{slug}-{uuid.uuid4().hex[:6]}"
|
|
256
|
+
meta = {
|
|
257
|
+
"id": page_id, "kind": "answer", "title": answer.question[:120], "question": answer.question,
|
|
258
|
+
"language": answer.language, "asked_at": answer.asked_at, "built_at": answer.commit,
|
|
259
|
+
"files": [{"path": path, "blob": manifest.files[path]} for path in answer.files_read if path in manifest.files],
|
|
260
|
+
} # fmt: skip
|
|
261
|
+
if guide.has_uncommitted_changes():
|
|
262
|
+
raise AssistantError("The guide has uncommitted edits; commit or discard them first.")
|
|
263
|
+
try:
|
|
264
|
+
guide.write_page(Page(page_id, meta, body))
|
|
265
|
+
guide.commit(f"Save the answer to: {answer.question[:60]}")
|
|
266
|
+
except BaseException:
|
|
267
|
+
guide.discard() # never leave the guide dirty, or every update would refuse to start
|
|
268
|
+
raise
|
|
269
|
+
return page_id
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def demote_unverified(body: str, context: ValidationContext) -> str:
|
|
273
|
+
"""Turns every documented block whose quote can't be verified into an inferred one (design section 7.3)."""
|
|
274
|
+
lines = body.splitlines()
|
|
275
|
+
for block in parse_rationale(body):
|
|
276
|
+
if block.kind != "documented":
|
|
277
|
+
continue
|
|
278
|
+
snippet = f"> [!documented] {block.citation or ''}\n" + "\n".join(
|
|
279
|
+
f"> {line}" for line in block.text.splitlines()
|
|
280
|
+
)
|
|
281
|
+
if validate_page(snippet, None, context):
|
|
282
|
+
index = block.line - 1
|
|
283
|
+
if MARKER.match(lines[index]):
|
|
284
|
+
lines[index] = "> [!inferred]"
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _event(payload: dict[str, Any]) -> bytes:
|
|
289
|
+
return (json.dumps(payload, ensure_ascii=False) + "\n").encode()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = ["AnswerChunk", "bridge_router", "demote_unverified"]
|