lovarch-cli 0.2.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.
- lovarch_cli/__init__.py +16 -0
- lovarch_cli/__main__.py +10 -0
- lovarch_cli/ai/__init__.py +21 -0
- lovarch_cli/ai/gateway.py +240 -0
- lovarch_cli/api.py +111 -0
- lovarch_cli/auth/__init__.py +32 -0
- lovarch_cli/auth/keyring_store.py +214 -0
- lovarch_cli/auth/local_server.py +165 -0
- lovarch_cli/auth/pkce.py +57 -0
- lovarch_cli/auth/session.py +189 -0
- lovarch_cli/cli.py +262 -0
- lovarch_cli/clients/__init__.py +33 -0
- lovarch_cli/clients/factory.py +54 -0
- lovarch_cli/clients/local_client.py +432 -0
- lovarch_cli/clients/lovarch_storage.py +174 -0
- lovarch_cli/clients/lovarch_supabase.py +295 -0
- lovarch_cli/clients/persistence.py +166 -0
- lovarch_cli/clients/storage.py +66 -0
- lovarch_cli/commands/__init__.py +10 -0
- lovarch_cli/commands/account.py +172 -0
- lovarch_cli/commands/audit.py +394 -0
- lovarch_cli/commands/config_cmd.py +80 -0
- lovarch_cli/commands/consolidate.py +217 -0
- lovarch_cli/commands/context_cmd.py +73 -0
- lovarch_cli/commands/dev.py +287 -0
- lovarch_cli/commands/do_cmd.py +120 -0
- lovarch_cli/commands/init.py +218 -0
- lovarch_cli/commands/jobs_cmd.py +95 -0
- lovarch_cli/commands/login.py +202 -0
- lovarch_cli/commands/mcp_cmd.py +26 -0
- lovarch_cli/commands/run.py +375 -0
- lovarch_cli/commands/signup.py +185 -0
- lovarch_cli/commands/status.py +243 -0
- lovarch_cli/commands/upgrade.py +108 -0
- lovarch_cli/commands/verifica_cmd.py +174 -0
- lovarch_cli/config.py +101 -0
- lovarch_cli/config_store.py +111 -0
- lovarch_cli/credits/__init__.py +35 -0
- lovarch_cli/credits/base.py +84 -0
- lovarch_cli/credits/factory.py +36 -0
- lovarch_cli/credits/local.py +34 -0
- lovarch_cli/credits/lovarch.py +56 -0
- lovarch_cli/i18n/__init__.py +27 -0
- lovarch_cli/i18n/loader.py +121 -0
- lovarch_cli/i18n/translations/en.json +168 -0
- lovarch_cli/i18n/translations/es.json +168 -0
- lovarch_cli/i18n/translations/it.json +168 -0
- lovarch_cli/i18n/translations/pt.json +168 -0
- lovarch_cli/mcp/__init__.py +9 -0
- lovarch_cli/mcp/server.py +199 -0
- lovarch_cli/mcp/tools.py +372 -0
- lovarch_cli/sample_downloader.py +255 -0
- lovarch_cli/squad/README.md +206 -0
- lovarch_cli/squad/agents/auditor-input.md +353 -0
- lovarch_cli/squad/agents/bim-engineer.md +404 -0
- lovarch_cli/squad/agents/briefing-architect.md +249 -0
- lovarch_cli/squad/agents/cad-engineer.md +278 -0
- lovarch_cli/squad/agents/capitolato-writer.md +256 -0
- lovarch_cli/squad/agents/computo-engineer.md +258 -0
- lovarch_cli/squad/agents/concept-designer.md +399 -0
- lovarch_cli/squad/agents/contratto-architect.md +243 -0
- lovarch_cli/squad/agents/deliverable-builder.md +253 -0
- lovarch_cli/squad/agents/energy-prelim.md +388 -0
- lovarch_cli/squad/agents/pratiche-it.md +251 -0
- lovarch_cli/squad/agents/progetto-chief.md +768 -0
- lovarch_cli/squad/agents/quality-dati.md +409 -0
- lovarch_cli/squad/agents/quality-misure.md +418 -0
- lovarch_cli/squad/agents/quality-normativa.md +417 -0
- lovarch_cli/squad/agents/quality-output.md +436 -0
- lovarch_cli/squad/agents/regolatorio-it.md +278 -0
- lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
- lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
- lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
- lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
- lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
- lovarch_cli/squad/config.yaml +408 -0
- lovarch_cli/squad/data/CHANGELOG.md +272 -0
- lovarch_cli/squad/data/agents-prd.md +428 -0
- lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
- lovarch_cli/squad/data/handoff-card-template.md +231 -0
- lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
- lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
- lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
- lovarch_cli/squad/scripts/api_clients.py +206 -0
- lovarch_cli/squad/scripts/architect_profile.py +276 -0
- lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
- lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
- lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
- lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
- lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
- lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
- lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
- lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
- lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
- lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
- lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
- lovarch_cli/squad/scripts/validate-squad.py +383 -0
- lovarch_cli/squad/tasks/audit-input.md +146 -0
- lovarch_cli/squad/tasks/compute-metric.md +105 -0
- lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
- lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
- lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
- lovarch_cli/squad/tasks/write-capitolato.md +100 -0
- lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
- lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
- lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
- lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
- lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
- lovarch_cli/squad_loader.py +114 -0
- lovarch_cli/verify/__init__.py +15 -0
- lovarch_cli/verify/contratto.py +110 -0
- lovarch_cli/verify/dossier.py +97 -0
- lovarch_cli/verify/misure.py +83 -0
- lovarch_cli/verify/normativa.py +178 -0
- lovarch_cli/version.py +13 -0
- lovarch_cli/workflows/__init__.py +9 -0
- lovarch_cli/workflows/platform.py +212 -0
- lovarch_cli-0.2.1.dist-info/METADATA +232 -0
- lovarch_cli-0.2.1.dist-info/RECORD +122 -0
- lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
- lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
- lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
lovarch_cli/mcp/tools.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Tool implementations for the Lovarch MCP server.
|
|
2
|
+
|
|
3
|
+
Kept as plain, dependency-injected functions (independent of the FastMCP wiring)
|
|
4
|
+
so they can be unit-tested directly. ``server.py`` wraps each of these in an
|
|
5
|
+
``@mcp.tool()``.
|
|
6
|
+
|
|
7
|
+
Every tool that runs paid AI goes through :class:`LovarchAiGateway`, so the
|
|
8
|
+
user's Lovarch credits are debited by the 1000cr=$1 rule — the MCP server never
|
|
9
|
+
calls a model provider directly.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from lovarch_cli.ai import AiGatewayError, InsufficientCreditsError, LovarchAiGateway
|
|
19
|
+
from lovarch_cli.credits.lovarch import LovarchCreditsClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _projects_root(home: Path | None = None) -> Path:
|
|
23
|
+
base = home or (Path.home() / ".lovarch")
|
|
24
|
+
return base / "projects"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def tool_whoami(session: Any) -> dict:
|
|
28
|
+
"""Report the authenticated Lovarch user and CLI mode."""
|
|
29
|
+
if session is None:
|
|
30
|
+
return {
|
|
31
|
+
"authenticated": False,
|
|
32
|
+
"mode": "none",
|
|
33
|
+
"hint": "Esegui `lovarch login --premium` per autenticarti.",
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
"authenticated": True,
|
|
37
|
+
"mode": "premium",
|
|
38
|
+
"user_id": session.user_id,
|
|
39
|
+
"email": session.email,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def tool_credits(session: Any) -> dict:
|
|
44
|
+
"""Return the user's Lovarch credit balance (does not debit)."""
|
|
45
|
+
if session is None:
|
|
46
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
47
|
+
balance = await LovarchCreditsClient(session).check()
|
|
48
|
+
return {
|
|
49
|
+
"balance": balance.balance,
|
|
50
|
+
"monthly_used": balance.monthly_used,
|
|
51
|
+
"credits_remaining": balance.credits_remaining,
|
|
52
|
+
"is_admin": balance.is_admin,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def tool_generate_image(
|
|
57
|
+
gateway: LovarchAiGateway | None,
|
|
58
|
+
*,
|
|
59
|
+
prompt: str,
|
|
60
|
+
output_path: str,
|
|
61
|
+
quality: str = "medium",
|
|
62
|
+
aspect: str = "1:1",
|
|
63
|
+
mode: str = "generate",
|
|
64
|
+
image_urls: list[str] | None = None,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""Generate an image via the platform (debits credits) and save it to disk.
|
|
67
|
+
|
|
68
|
+
Returns the saved path and the exact number of credits charged.
|
|
69
|
+
"""
|
|
70
|
+
if gateway is None:
|
|
71
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
72
|
+
try:
|
|
73
|
+
result = await gateway.generate_image(
|
|
74
|
+
prompt,
|
|
75
|
+
quality=quality, # type: ignore[arg-type]
|
|
76
|
+
aspect=aspect,
|
|
77
|
+
mode=mode, # type: ignore[arg-type]
|
|
78
|
+
image_urls=image_urls,
|
|
79
|
+
operation_type="mcp:generate_image",
|
|
80
|
+
)
|
|
81
|
+
except InsufficientCreditsError as exc:
|
|
82
|
+
return {
|
|
83
|
+
"ok": False,
|
|
84
|
+
"error": "insufficient_credits",
|
|
85
|
+
"credits_available": exc.available,
|
|
86
|
+
"credits_needed": exc.needed,
|
|
87
|
+
}
|
|
88
|
+
except AiGatewayError as exc:
|
|
89
|
+
return {"ok": False, "error": str(exc)}
|
|
90
|
+
|
|
91
|
+
out = Path(output_path).expanduser()
|
|
92
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
out.write_bytes(result.image_bytes)
|
|
94
|
+
return {
|
|
95
|
+
"ok": True,
|
|
96
|
+
"saved_to": str(out),
|
|
97
|
+
"content_type": result.content_type,
|
|
98
|
+
"credits_charged": result.credits_charged,
|
|
99
|
+
"balance": result.balance,
|
|
100
|
+
"revised_prompt": result.revised_prompt,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def tool_audit_input(project_dir: str) -> dict:
|
|
105
|
+
"""Run the 18-point input audit on a project's ``input/`` directory."""
|
|
106
|
+
# Imported lazily to avoid a hard dependency at module import time.
|
|
107
|
+
from lovarch_cli.commands.audit import _overall_verdict, _run_checks
|
|
108
|
+
|
|
109
|
+
root = Path(project_dir).expanduser()
|
|
110
|
+
input_dir = root / "input" if (root / "input").exists() else root
|
|
111
|
+
if not input_dir.exists():
|
|
112
|
+
return {"error": "input_dir_not_found", "path": str(input_dir)}
|
|
113
|
+
results = _run_checks(input_dir)
|
|
114
|
+
verdict = _overall_verdict(results)
|
|
115
|
+
return {
|
|
116
|
+
"verdict": verdict.value,
|
|
117
|
+
"checks": [
|
|
118
|
+
{"index": r.index, "key": r.key, "status": r.status.value,
|
|
119
|
+
"detail": r.detail, "required": r.required}
|
|
120
|
+
for r in results
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def tool_list_projects(home: Path | None = None) -> dict:
|
|
126
|
+
"""List local Lovarch projects with their workflow + last audit verdict."""
|
|
127
|
+
root = _projects_root(home)
|
|
128
|
+
if not root.exists():
|
|
129
|
+
return {"projects": []}
|
|
130
|
+
projects = []
|
|
131
|
+
for child in sorted(root.iterdir()):
|
|
132
|
+
meta_file = child / "project.yaml"
|
|
133
|
+
if not meta_file.is_file():
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
meta = yaml.safe_load(meta_file.read_text()) or {}
|
|
137
|
+
except yaml.YAMLError:
|
|
138
|
+
meta = {}
|
|
139
|
+
projects.append({
|
|
140
|
+
"name": child.name,
|
|
141
|
+
"workflow": meta.get("workflow"),
|
|
142
|
+
"last_audit": (meta.get("last_audit") or {}).get("verdict"),
|
|
143
|
+
"last_run": (meta.get("last_run") or {}).get("status"),
|
|
144
|
+
})
|
|
145
|
+
return {"projects": projects}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def tool_ai_text(
|
|
149
|
+
gateway: Any,
|
|
150
|
+
*,
|
|
151
|
+
prompt: str,
|
|
152
|
+
role: str = "executor",
|
|
153
|
+
model: str | None = None,
|
|
154
|
+
system: str | None = None,
|
|
155
|
+
max_tokens: int | None = None,
|
|
156
|
+
language: str | None = None,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""Generate text via the platform gateway (debits credits by real tokens)."""
|
|
159
|
+
if gateway is None:
|
|
160
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
161
|
+
try:
|
|
162
|
+
result = await gateway.generate_text(
|
|
163
|
+
prompt, role=role, model=model, system=system,
|
|
164
|
+
max_tokens=max_tokens, language=language,
|
|
165
|
+
operation_type="mcp:ai_text",
|
|
166
|
+
)
|
|
167
|
+
except InsufficientCreditsError as exc:
|
|
168
|
+
return {"ok": False, "error": "insufficient_credits",
|
|
169
|
+
"credits_available": exc.available, "credits_needed": exc.needed}
|
|
170
|
+
except AiGatewayError as exc:
|
|
171
|
+
return {"ok": False, "error": str(exc)}
|
|
172
|
+
return {
|
|
173
|
+
"ok": True,
|
|
174
|
+
"text": result.text,
|
|
175
|
+
"model": result.model,
|
|
176
|
+
"credits_charged": result.credits_charged,
|
|
177
|
+
"balance": result.balance,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def tool_user_context(gateway: Any, *, lead_id: str | None = None) -> dict:
|
|
182
|
+
"""Fetch the user's personalization bundle (brand, style, signature, language)."""
|
|
183
|
+
if gateway is None:
|
|
184
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
185
|
+
try:
|
|
186
|
+
return await gateway.get_user_context(lead_id=lead_id)
|
|
187
|
+
except AiGatewayError as exc:
|
|
188
|
+
return {"ok": False, "error": str(exc)}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def tool_render(
|
|
192
|
+
workflows: Any,
|
|
193
|
+
*,
|
|
194
|
+
description: str,
|
|
195
|
+
output_path: str,
|
|
196
|
+
mode: str | None = None,
|
|
197
|
+
render_style: str = "moderno",
|
|
198
|
+
aspect_ratio: str = "16:9",
|
|
199
|
+
reference_image_path: str | None = None,
|
|
200
|
+
language: str = "it",
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""Photorealistic render via the platform (credits debited server-side)."""
|
|
203
|
+
if workflows is None:
|
|
204
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
205
|
+
from lovarch_cli.workflows import WorkflowError
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = await workflows.render(
|
|
209
|
+
description, mode=mode, render_style=render_style,
|
|
210
|
+
aspect_ratio=aspect_ratio, reference_image_path=reference_image_path,
|
|
211
|
+
language=language,
|
|
212
|
+
)
|
|
213
|
+
except WorkflowError as exc:
|
|
214
|
+
return {"ok": False, "error": str(exc)}
|
|
215
|
+
out: dict = {"ok": True, "message": result.message}
|
|
216
|
+
image_bytes = result.image_bytes
|
|
217
|
+
if result.image_url:
|
|
218
|
+
# The EF persisted the render in the user's Lovarch storage — keep the
|
|
219
|
+
# URL AND download a local copy for CLI convenience (best-effort).
|
|
220
|
+
out["image_url"] = result.image_url
|
|
221
|
+
if image_bytes is None:
|
|
222
|
+
try:
|
|
223
|
+
import httpx
|
|
224
|
+
|
|
225
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
226
|
+
resp = await client.get(result.image_url)
|
|
227
|
+
if resp.status_code == 200:
|
|
228
|
+
image_bytes = resp.content
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
image_bytes = None
|
|
231
|
+
if image_bytes:
|
|
232
|
+
p = Path(output_path).expanduser()
|
|
233
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
p.write_bytes(image_bytes)
|
|
235
|
+
out["saved_to"] = str(p)
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def tool_colors(
|
|
240
|
+
workflows: Any,
|
|
241
|
+
*,
|
|
242
|
+
style: str = "modern",
|
|
243
|
+
base_colors: list[str] | None = None,
|
|
244
|
+
image_url: str | None = None,
|
|
245
|
+
language: str = "it",
|
|
246
|
+
) -> dict:
|
|
247
|
+
"""Brand color palette via the platform."""
|
|
248
|
+
if workflows is None:
|
|
249
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
250
|
+
from lovarch_cli.workflows import WorkflowError
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
return await workflows.colors(
|
|
254
|
+
style=style, base_colors=base_colors, image_url=image_url, language=language,
|
|
255
|
+
)
|
|
256
|
+
except WorkflowError as exc:
|
|
257
|
+
return {"ok": False, "error": str(exc)}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def tool_copy(
|
|
261
|
+
workflows: Any,
|
|
262
|
+
*,
|
|
263
|
+
brief: str,
|
|
264
|
+
mode: str = "post",
|
|
265
|
+
slide_count: int = 5,
|
|
266
|
+
language: str = "it",
|
|
267
|
+
) -> dict:
|
|
268
|
+
"""Marketing copy (caption + hashtags) via the platform."""
|
|
269
|
+
if workflows is None:
|
|
270
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
271
|
+
from lovarch_cli.workflows import WorkflowError
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
return await workflows.copy(brief, mode=mode, slide_count=slide_count, language=language)
|
|
275
|
+
except WorkflowError as exc:
|
|
276
|
+
return {"ok": False, "error": str(exc)}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def tool_verify_misure(dxf_path: str) -> dict:
|
|
280
|
+
"""Deterministic DXF check (ISO layers, room labels, CNAPPC cartiglio). Free."""
|
|
281
|
+
from lovarch_cli.verify import verify_misure
|
|
282
|
+
|
|
283
|
+
report = verify_misure(dxf_path)
|
|
284
|
+
return {"verdict": report.verdict, "findings": report.findings, "stats": report.stats}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def tool_verify_normativa(
|
|
288
|
+
gateway: Any, *, document_path: str, language: str = "it"
|
|
289
|
+
) -> dict:
|
|
290
|
+
"""Adversarial normative-citation check (Sonnet extracts → Opus refutes)."""
|
|
291
|
+
if gateway is None:
|
|
292
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
293
|
+
from lovarch_cli.verify import verify_normativa
|
|
294
|
+
from lovarch_cli.verify.normativa import NormativaError
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
report = await verify_normativa(gateway, document_path, language=language)
|
|
298
|
+
except NormativaError as exc:
|
|
299
|
+
return {"ok": False, "error": str(exc)}
|
|
300
|
+
except InsufficientCreditsError as exc:
|
|
301
|
+
return {"ok": False, "error": "insufficient_credits",
|
|
302
|
+
"credits_available": exc.available, "credits_needed": exc.needed}
|
|
303
|
+
except AiGatewayError as exc:
|
|
304
|
+
return {"ok": False, "error": str(exc)}
|
|
305
|
+
return {
|
|
306
|
+
"ok": True,
|
|
307
|
+
"verdict": report.verdict,
|
|
308
|
+
"citations": report.citations,
|
|
309
|
+
"verdicts": report.verdicts,
|
|
310
|
+
"canonical_found": report.canonical_found,
|
|
311
|
+
"credits_charged": report.credits_charged,
|
|
312
|
+
"notes": report.notes,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def tool_job_status(workflows: Any, *, job_id: str | None = None, limit: int = 10) -> dict:
|
|
317
|
+
"""Async job status (video/export/upscale). Without job_id lists recent jobs."""
|
|
318
|
+
if workflows is None:
|
|
319
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
320
|
+
from lovarch_cli.workflows import WorkflowError
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
if job_id:
|
|
324
|
+
return {"ok": True, "job": await workflows.job_status(job_id)}
|
|
325
|
+
return {"ok": True, "jobs": await workflows.jobs_list(limit=limit)}
|
|
326
|
+
except WorkflowError as exc:
|
|
327
|
+
return {"ok": False, "error": str(exc)}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def tool_verify_contratto(
|
|
331
|
+
gateway: Any, *, document_path: str, language: str = "it"
|
|
332
|
+
) -> dict:
|
|
333
|
+
"""Adversarial CNAPPC contract check (structure + compenso QN_007)."""
|
|
334
|
+
if gateway is None:
|
|
335
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
336
|
+
from lovarch_cli.verify import verify_contratto
|
|
337
|
+
from lovarch_cli.verify.normativa import NormativaError
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
report = await verify_contratto(gateway, document_path, language=language)
|
|
341
|
+
except NormativaError as exc:
|
|
342
|
+
return {"ok": False, "error": str(exc)}
|
|
343
|
+
except InsufficientCreditsError as exc:
|
|
344
|
+
return {"ok": False, "error": "insufficient_credits",
|
|
345
|
+
"credits_available": exc.available, "credits_needed": exc.needed}
|
|
346
|
+
except AiGatewayError as exc:
|
|
347
|
+
return {"ok": False, "error": str(exc)}
|
|
348
|
+
return {"ok": True, "verdict": report.verdict, "structure": report.structure,
|
|
349
|
+
"findings": report.findings, "credits_charged": report.credits_charged}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def tool_verify_dossier(
|
|
353
|
+
gateway: Any, *, directory: str, language: str = "it", max_llm_files: int = 8
|
|
354
|
+
) -> dict:
|
|
355
|
+
"""Full standalone QA over a deliverables folder."""
|
|
356
|
+
if gateway is None:
|
|
357
|
+
return {"error": "not_authenticated", "hint": "Esegui `lovarch login --premium`."}
|
|
358
|
+
from lovarch_cli.verify import verify_dossier
|
|
359
|
+
from lovarch_cli.verify.normativa import NormativaError
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
report = await verify_dossier(gateway, directory, language=language,
|
|
363
|
+
max_llm_files=max_llm_files)
|
|
364
|
+
except NormativaError as exc:
|
|
365
|
+
return {"ok": False, "error": str(exc)}
|
|
366
|
+
except InsufficientCreditsError as exc:
|
|
367
|
+
return {"ok": False, "error": "insufficient_credits",
|
|
368
|
+
"credits_available": exc.available, "credits_needed": exc.needed}
|
|
369
|
+
except AiGatewayError as exc:
|
|
370
|
+
return {"ok": False, "error": str(exc)}
|
|
371
|
+
return {"ok": True, "verdict": report.verdict, "files": report.files,
|
|
372
|
+
"skipped": report.skipped, "credits_charged": report.credits_charged}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Lazy downloader for the villa-chianti sample-input asset.
|
|
2
|
+
|
|
3
|
+
The 49MB sample-input (photos/DXF/PDF) is NOT bundled in the wheel — it ships
|
|
4
|
+
via GitHub Releases asset `sample-villa-chianti.zip` and is fetched on demand
|
|
5
|
+
by `lovarch init --sample`.
|
|
6
|
+
|
|
7
|
+
Flow when `arch init --sample` is invoked:
|
|
8
|
+
|
|
9
|
+
1. Check if bundled sample exists at lovarch_cli/squad/data/sample-input-
|
|
10
|
+
villa-chianti/. If yes → return its path (offline/dev case).
|
|
11
|
+
2. Check cache at ~/.lovarch/cache/sample-villa-chianti/. If extracted
|
|
12
|
+
and SHA-verified → return cached path.
|
|
13
|
+
3. Download sample-villa-chianti.zip from GitHub Releases with progress
|
|
14
|
+
bar, verify SHA256, extract to cache, return path.
|
|
15
|
+
|
|
16
|
+
The SHA256 is pinned per release (see SAMPLE_ASSET_SHA256). Bumping requires
|
|
17
|
+
re-pinning here. Pre-release builds may set this to None to skip verification
|
|
18
|
+
(NOT for prod).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import shutil
|
|
24
|
+
import zipfile
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.progress import (
|
|
32
|
+
BarColumn,
|
|
33
|
+
DownloadColumn,
|
|
34
|
+
Progress,
|
|
35
|
+
TextColumn,
|
|
36
|
+
TimeRemainingColumn,
|
|
37
|
+
TransferSpeedColumn,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from lovarch_cli.config import DEFAULT_HOME
|
|
41
|
+
from lovarch_cli.i18n import t
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Release pinning — bump together when shipping a new sample.
|
|
45
|
+
SAMPLE_RELEASE_TAG = "v0.1.0-beta.1"
|
|
46
|
+
SAMPLE_ASSET_NAME = "sample-villa-chianti.zip"
|
|
47
|
+
SAMPLE_ASSET_URL = (
|
|
48
|
+
f"https://github.com/ArchPrime-official/lovarch-cli/releases/download/"
|
|
49
|
+
f"{SAMPLE_RELEASE_TAG}/{SAMPLE_ASSET_NAME}"
|
|
50
|
+
)
|
|
51
|
+
SAMPLE_ASSET_SHA256 = (
|
|
52
|
+
"fa3c057adb9ea70c8338fa1102f1c807524ae847768ad01238cdf364fd335be4"
|
|
53
|
+
)
|
|
54
|
+
# When extracted, the zip contains a top-level dir named sample-input-villa-
|
|
55
|
+
# chianti/ — we keep this convention so the resulting cache path matches the
|
|
56
|
+
# bundled-squad path used elsewhere.
|
|
57
|
+
EXTRACTED_DIR_NAME = "sample-input-villa-chianti"
|
|
58
|
+
|
|
59
|
+
# Network limits.
|
|
60
|
+
DOWNLOAD_TIMEOUT_SECONDS = 120
|
|
61
|
+
DOWNLOAD_CHUNK_SIZE = 64 * 1024
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SampleSource:
|
|
66
|
+
"""Result of resolving the sample-input source path."""
|
|
67
|
+
|
|
68
|
+
path: Path
|
|
69
|
+
origin: str # "bundled" | "cache" | "download"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SampleDownloadError(RuntimeError):
|
|
73
|
+
"""Raised when the sample asset cannot be obtained or verified."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bundled_dir(squad_src: Optional[Path] = None) -> Path:
|
|
77
|
+
"""Path the build hook would populate (if sample bundled).
|
|
78
|
+
|
|
79
|
+
Honors the same override chain as the runner via squad_loader: an
|
|
80
|
+
explicit `squad_src` argument > `$LOVARCH_SQUAD_SRC` env var > bundled
|
|
81
|
+
vendor. Falls back silently to the bundled path if resolution raises
|
|
82
|
+
(so the lazy-download flow still kicks in instead of crashing).
|
|
83
|
+
"""
|
|
84
|
+
from lovarch_cli.squad_loader import SquadNotFoundError, resolve_squad_root
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
root = resolve_squad_root(override=squad_src)
|
|
88
|
+
except SquadNotFoundError:
|
|
89
|
+
# Resolution failed (no override, no bundled) — fall through to the
|
|
90
|
+
# bundled path so downstream cache/download logic handles "not
|
|
91
|
+
# bundled" naturally.
|
|
92
|
+
root = Path(__file__).resolve().parent / "squad"
|
|
93
|
+
return root / "data" / EXTRACTED_DIR_NAME
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cache_root(home: Optional[Path] = None) -> Path:
|
|
97
|
+
base = home or DEFAULT_HOME
|
|
98
|
+
return base / "cache"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cached_dir(home: Optional[Path] = None) -> Path:
|
|
102
|
+
return _cache_root(home) / EXTRACTED_DIR_NAME
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cached_zip(home: Optional[Path] = None) -> Path:
|
|
106
|
+
return _cache_root(home) / SAMPLE_ASSET_NAME
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _sha256_of(path: Path) -> str:
|
|
110
|
+
hasher = hashlib.sha256()
|
|
111
|
+
with path.open("rb") as fh:
|
|
112
|
+
for chunk in iter(lambda: fh.read(DOWNLOAD_CHUNK_SIZE), b""):
|
|
113
|
+
hasher.update(chunk)
|
|
114
|
+
return hasher.hexdigest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _download_with_progress(
|
|
118
|
+
url: str,
|
|
119
|
+
dst: Path,
|
|
120
|
+
console: Console,
|
|
121
|
+
lang: Optional[str],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Stream-download `url` to `dst` with a rich progress bar."""
|
|
124
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
tmp = dst.with_suffix(dst.suffix + ".partial")
|
|
126
|
+
try:
|
|
127
|
+
with httpx.stream(
|
|
128
|
+
"GET",
|
|
129
|
+
url,
|
|
130
|
+
timeout=DOWNLOAD_TIMEOUT_SECONDS,
|
|
131
|
+
follow_redirects=True,
|
|
132
|
+
) as response:
|
|
133
|
+
response.raise_for_status()
|
|
134
|
+
total = int(response.headers.get("content-length", 0)) or None
|
|
135
|
+
with Progress(
|
|
136
|
+
TextColumn("[bold gold1]⬇ {task.description}"),
|
|
137
|
+
BarColumn(bar_width=None),
|
|
138
|
+
DownloadColumn(),
|
|
139
|
+
TransferSpeedColumn(),
|
|
140
|
+
TimeRemainingColumn(),
|
|
141
|
+
console=console,
|
|
142
|
+
transient=True,
|
|
143
|
+
) as progress:
|
|
144
|
+
task_id = progress.add_task(
|
|
145
|
+
t("init.downloading", lang=lang, asset=SAMPLE_ASSET_NAME),
|
|
146
|
+
total=total,
|
|
147
|
+
)
|
|
148
|
+
with tmp.open("wb") as fh:
|
|
149
|
+
for chunk in response.iter_bytes(DOWNLOAD_CHUNK_SIZE):
|
|
150
|
+
fh.write(chunk)
|
|
151
|
+
progress.update(task_id, advance=len(chunk))
|
|
152
|
+
tmp.replace(dst)
|
|
153
|
+
except httpx.HTTPError as exc:
|
|
154
|
+
tmp.unlink(missing_ok=True)
|
|
155
|
+
raise SampleDownloadError(
|
|
156
|
+
t("init.download_failed", lang=lang, url=url, error=str(exc))
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _verify_zip(zip_path: Path, lang: Optional[str]) -> None:
|
|
161
|
+
if not SAMPLE_ASSET_SHA256:
|
|
162
|
+
return # Pre-release / explicit skip
|
|
163
|
+
actual = _sha256_of(zip_path)
|
|
164
|
+
if actual != SAMPLE_ASSET_SHA256:
|
|
165
|
+
raise SampleDownloadError(
|
|
166
|
+
t(
|
|
167
|
+
"init.checksum_failed",
|
|
168
|
+
lang=lang,
|
|
169
|
+
expected=SAMPLE_ASSET_SHA256,
|
|
170
|
+
actual=actual,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_into_cache(
|
|
176
|
+
zip_path: Path,
|
|
177
|
+
cache_root: Path,
|
|
178
|
+
lang: Optional[str],
|
|
179
|
+
) -> Path:
|
|
180
|
+
"""Extract zip into cache_root/EXTRACTED_DIR_NAME, replacing any prior copy."""
|
|
181
|
+
target = cache_root / EXTRACTED_DIR_NAME
|
|
182
|
+
if target.exists():
|
|
183
|
+
shutil.rmtree(target)
|
|
184
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
try:
|
|
186
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
187
|
+
# Defense against path-traversal in zip entries.
|
|
188
|
+
for name in zf.namelist():
|
|
189
|
+
resolved = (cache_root / name).resolve()
|
|
190
|
+
if cache_root.resolve() not in resolved.parents and resolved != (
|
|
191
|
+
cache_root / EXTRACTED_DIR_NAME
|
|
192
|
+
).resolve():
|
|
193
|
+
if not str(resolved).startswith(str(cache_root.resolve())):
|
|
194
|
+
raise SampleDownloadError(
|
|
195
|
+
t("init.zip_unsafe", lang=lang, entry=name)
|
|
196
|
+
)
|
|
197
|
+
zf.extractall(cache_root)
|
|
198
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
199
|
+
raise SampleDownloadError(
|
|
200
|
+
t("init.extract_failed", lang=lang, error=str(exc))
|
|
201
|
+
) from exc
|
|
202
|
+
if not target.exists() or not target.is_dir():
|
|
203
|
+
raise SampleDownloadError(
|
|
204
|
+
t("init.unexpected_zip_layout", lang=lang, expected=EXTRACTED_DIR_NAME)
|
|
205
|
+
)
|
|
206
|
+
return target
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def resolve_sample_source(
|
|
210
|
+
*,
|
|
211
|
+
console: Console,
|
|
212
|
+
lang: Optional[str] = None,
|
|
213
|
+
home: Optional[Path] = None,
|
|
214
|
+
allow_download: bool = True,
|
|
215
|
+
squad_src: Optional[Path] = None,
|
|
216
|
+
) -> SampleSource:
|
|
217
|
+
"""Return a populated SampleSource, downloading & caching if needed.
|
|
218
|
+
|
|
219
|
+
Resolution order:
|
|
220
|
+
1. Bundled inside the resolved squad root (honors --squad-src /
|
|
221
|
+
$LOVARCH_SQUAD_SRC overrides — see lovarch_cli.squad_loader)
|
|
222
|
+
2. Cached extraction in ~/.lovarch/cache/...
|
|
223
|
+
3. Fresh download from GitHub Releases (if allow_download)
|
|
224
|
+
|
|
225
|
+
Raises SampleDownloadError on network/integrity/IO failures when neither
|
|
226
|
+
bundled nor cache is usable.
|
|
227
|
+
"""
|
|
228
|
+
bundled = _bundled_dir(squad_src=squad_src)
|
|
229
|
+
if bundled.is_dir() and any(bundled.iterdir()):
|
|
230
|
+
return SampleSource(path=bundled, origin="bundled")
|
|
231
|
+
|
|
232
|
+
cached = _cached_dir(home)
|
|
233
|
+
if cached.is_dir() and any(cached.iterdir()):
|
|
234
|
+
return SampleSource(path=cached, origin="cache")
|
|
235
|
+
|
|
236
|
+
if not allow_download:
|
|
237
|
+
raise SampleDownloadError(t("init.no_sample_offline", lang=lang))
|
|
238
|
+
|
|
239
|
+
zip_path = _cached_zip(home)
|
|
240
|
+
if zip_path.exists():
|
|
241
|
+
# Reuse a previously-downloaded zip if checksum still matches.
|
|
242
|
+
try:
|
|
243
|
+
_verify_zip(zip_path, lang)
|
|
244
|
+
except SampleDownloadError:
|
|
245
|
+
zip_path.unlink(missing_ok=True)
|
|
246
|
+
|
|
247
|
+
if not zip_path.exists():
|
|
248
|
+
console.print(
|
|
249
|
+
f"[dim]{t('init.fetching_sample', lang=lang, url=SAMPLE_ASSET_URL)}[/dim]"
|
|
250
|
+
)
|
|
251
|
+
_download_with_progress(SAMPLE_ASSET_URL, zip_path, console, lang)
|
|
252
|
+
_verify_zip(zip_path, lang)
|
|
253
|
+
|
|
254
|
+
extracted = _extract_into_cache(zip_path, _cache_root(home), lang)
|
|
255
|
+
return SampleSource(path=extracted, origin="download")
|