okstra 0.158.1 → 0.159.0
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.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/render_final_report.py +4 -40
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/schemas/final-report-v2.0.schema.json +5 -1
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +25 -0
- package/runtime/templates/reports/html/i18n/ko.json +25 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/validators/validate-run.py +64 -0
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -102,7 +102,7 @@ Rules (the schema enforces most of these — they are listed here so you know *w
|
|
|
102
102
|
- Read the exact permitted header values from the task bundle schema excerpt. In the current v2 contract, `header.reportOwner` is `"Okstra lead"` and `header.reportAuthor` is `"Report writer worker"`. Set author to `"Okstra lead"` only for `release-handoff` runs (single-lead by design) or a recorded report-writer dispatch failure fallback. A legacy v1 excerpt may retain its historical compatibility values; follow that excerpt rather than inferring ownership from the provider.
|
|
103
103
|
- **Source items (worker:item) preservation.** Every `consensus[].sourceItems`, `differences[].workersPosition[].itemId`, and `evidence.primary[].sourceItems` entry MUST carry the worker:item-id pair (e.g. `claude:F-001`, `codex:1.1`, `antigravity:F-3`, or `lead:mcp-1` for lead-only evidence). The schema enforces this via the `SourceItem` regex; bare worker-name lists no longer parse.
|
|
104
104
|
- **Verdict Card consistency.** `verdictCard.verdictToken` and `verdictCard.direction` MUST byte-match `finalVerdict.verdictToken` / `.direction`; `validators/validate-run.py` diffs both and fails the run on divergence. `verdictCard.nextStep` names the same action as `finalVerdict.nextStep` and `recommendedNextSteps[0].text` but is written as the actionable command the reader runs (e.g. `/okstra-run task-key=… task-type=release-handoff`) where the other two are prose — it is deliberately not a byte copy. Duplicating the compared values across `verdictCard` and `finalVerdict` is intentional so the validator can diff them.
|
|
105
|
-
- **Error-analysis diagnosis and routing.** When `header.taskType` is `error-analysis`, populate the required `errorAnalysis` object. Copy `errorAnalysis.symptomVerbatim` byte-for-byte from the symptom stated in the brief's `Source Material`; do not paraphrase it. Every `causeCandidates[]` row includes the full `supportingEvidence`, `falsifyingEvidenceChecked`, `confidence`, and `disproveWith` fields. Route `errorAnalysis.routing.nextTaskType=implementation-planning` with `direction=begin-planning`, or route `errorAnalysis.routing.nextTaskType=error-analysis` with `direction=continue-investigation`; no other pairing is valid. `verdictCard.nextStep`, `finalVerdict.nextStep`, the first `recommendedNextSteps` action and command, and the unique `followUpTasks` row whose `origin` is `phase-continuation` MUST all point to the same `errorAnalysis.routing.nextTaskType` target. The schema enforces only the presence of a `phase-continuation` row. Phase validation MUST enforce exact target agreement and uniqueness through `validators/validate-run.py::_validate_error_analysis_consistency`; until that check is implemented and executed, those semantics are contract requirements rather than enforced guarantees.
|
|
105
|
+
- **Error-analysis diagnosis and routing.** When `header.taskType` is `error-analysis`, populate the required `errorAnalysis` object. Copy `errorAnalysis.symptomVerbatim` byte-for-byte from the symptom stated in the brief's `Source Material`; do not paraphrase it. Every `causeCandidates[]` row includes the full `supportingEvidence`, `falsifyingEvidenceChecked`, `confidence`, and `disproveWith` fields. When a candidate is a step in a propagation chain rather than a competing explanation — the analysis calls it a downstream step, a second stage, or a consequence of another candidate — set its `downstreamOf` to the ids of the candidates immediately upstream of it; leave the field absent for a candidate that stands on its own. Every id listed MUST be another candidate in the same report, no row may name itself, and the links MUST NOT form a cycle; `validators/validate-run.py::_validate_cause_chain` rejects all three. This is the only place the chain is machine-readable — prose calling a candidate "the second step of the chain" while `downstreamOf` is absent leaves the report's figure claiming the candidates are alternatives. Route `errorAnalysis.routing.nextTaskType=implementation-planning` with `direction=begin-planning`, or route `errorAnalysis.routing.nextTaskType=error-analysis` with `direction=continue-investigation`; no other pairing is valid. `verdictCard.nextStep`, `finalVerdict.nextStep`, the first `recommendedNextSteps` action and command, and the unique `followUpTasks` row whose `origin` is `phase-continuation` MUST all point to the same `errorAnalysis.routing.nextTaskType` target. The schema enforces only the presence of a `phase-continuation` row. Phase validation MUST enforce exact target agreement and uniqueness through `validators/validate-run.py::_validate_error_analysis_consistency`; until that check is implemented and executed, those semantics are contract requirements rather than enforced guarantees.
|
|
106
106
|
- **Human narrative.** Populate required `humanSummary` and the selected task block's `userNarrative`. Human-visible analysis facts must not exist only in Markdown; HTML is derived independently and can use only data.json. Keep worker discussion and audit details in `crossVerification`, `executionStatus`, and `tokenUsage`, outside the human narrative fields.
|
|
107
107
|
- **External QA advisory.** A Tier 3 entry requiring `db`, `http`, or
|
|
108
108
|
`external` may be non-PASS without changing approval or final verdict. Render
|
|
@@ -330,7 +330,7 @@ Every field MUST anchor its claim with at least one evidence reference — a `pa
|
|
|
330
330
|
0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub. The lead calls `okstra incremental-scope` exactly once, combining answered-clarification stage impacts (`--impacted`) and changed PREP IDs (`--prep-items`); selected-option, Stage Map, or recommended-approach changes pass both CSVs empty to force full mode. Record that single decision JSON verbatim into `implementationPlanning.incrementalDecision` (`mode`, `reverifyStages`, `carryStages`, `reason`); the renderer emits the `### 0.1 Incremental Re-Verification Scope` audit block from it, and the validator fails an `incremental`-mode run whose Section 0 omits that block. In `incremental` mode this run's `planItems` MUST carry every plan-item id from the re-verified stages forward with its updated verdict; if re-verification concludes a plan item should be REMOVED, that is a signal the answer's blast radius is not local — do not drop it here, tell the lead to abandon incremental and re-route to a FULL re-verification, because the carry merge only adds prior items and would resurrect the removed item's stale verdict. After authoring the current data.json, call `okstra incremental-carry`, passing the decision's `carryStages` CSV to `--carry-stages` and its `reverifyStages` CSV to `--reverify-stages`. A `CarryError` means the stage/PREP ownership contract is unsafe: discard the partial merged output and route the run through full re-verification; never publish a partially merged report.
|
|
331
331
|
1. **Clarification Items** — single unified `C-*` table; column schema (4 columns with the short fields stacked in one record-meta cell), ID convention, and rerun behaviour are owned by `_common-contract.md §Clarification request policy` (SSOT). The deprecated `5.5.9 Open Questions` / `1.1 Additional Material Request` / `1.2 User Confirmation Questions` sub-sections are removed; the validator fails reports that reintroduce them.
|
|
332
332
|
2. **Evidence and Detailed Analysis** — primary evidence rows (file path, line, snippet); secondary evidence / alternate interpretations. If `reference-expectations.md` lists explicit expected values, record match/gap per row.
|
|
333
|
-
- **Error-analysis diagnosis and routing.** When `header.taskType` is `error-analysis`, populate the required `errorAnalysis` object. Copy `errorAnalysis.symptomVerbatim` byte-for-byte from the symptom stated in the brief's `Source Material`; do not paraphrase it. Every `causeCandidates[]` row includes the full `supportingEvidence`, `falsifyingEvidenceChecked`, `confidence`, and `disproveWith` fields. Route `errorAnalysis.routing.nextTaskType=implementation-planning` with `direction=begin-planning`, or route `errorAnalysis.routing.nextTaskType=error-analysis` with `direction=continue-investigation`; no other pairing is valid. `verdictCard.nextStep`, `finalVerdict.nextStep`, the first `recommendedNextSteps` action and command, and the unique `followUpTasks` row whose `origin` is `phase-continuation` MUST all point to the same `errorAnalysis.routing.nextTaskType` target. The schema enforces only the presence of a `phase-continuation` row. Phase validation MUST enforce exact target agreement and uniqueness through `validators/validate-run.py::_validate_error_analysis_consistency`; until that check is implemented and executed, those semantics are contract requirements rather than enforced guarantees.
|
|
333
|
+
- **Error-analysis diagnosis and routing.** When `header.taskType` is `error-analysis`, populate the required `errorAnalysis` object. Copy `errorAnalysis.symptomVerbatim` byte-for-byte from the symptom stated in the brief's `Source Material`; do not paraphrase it. Every `causeCandidates[]` row includes the full `supportingEvidence`, `falsifyingEvidenceChecked`, `confidence`, and `disproveWith` fields. When a candidate is a step in a propagation chain rather than a competing explanation — the analysis calls it a downstream step, a second stage, or a consequence of another candidate — set its `downstreamOf` to the ids of the candidates immediately upstream of it; leave the field absent for a candidate that stands on its own. Every id listed MUST be another candidate in the same report, no row may name itself, and the links MUST NOT form a cycle; `validators/validate-run.py::_validate_cause_chain` rejects all three. This is the only place the chain is machine-readable — prose calling a candidate "the second step of the chain" while `downstreamOf` is absent leaves the report's figure claiming the candidates are alternatives. Route `errorAnalysis.routing.nextTaskType=implementation-planning` with `direction=begin-planning`, or route `errorAnalysis.routing.nextTaskType=error-analysis` with `direction=continue-investigation`; no other pairing is valid. `verdictCard.nextStep`, `finalVerdict.nextStep`, the first `recommendedNextSteps` action and command, and the unique `followUpTasks` row whose `origin` is `phase-continuation` MUST all point to the same `errorAnalysis.routing.nextTaskType` target. The schema enforces only the presence of a `phase-continuation` row. Phase validation MUST enforce exact target agreement and uniqueness through `validators/validate-run.py::_validate_error_analysis_consistency`; until that check is implemented and executed, those semantics are contract requirements rather than enforced guarantees.
|
|
334
334
|
3. **Recommended Next Steps** — prioritized actions. After Phase 7's follow-up spawner runs, append a row per newly created task-key (see "Phase 6 → Phase 7 execution sequence" above). **Approval-gate consistency:** when §1 carries any `Blocks: approval` row with `Status` ∈ {open, answered}, the Verdict Card `Next Step` and the first recommended step MUST point to the clarification rerun (`resume-clarification` of the SAME task-type) — never to "flip frontmatter `approved: true` → jump straight to `implementation`". Run-prep enforces this gate (`run.py _validate_approved_plan` fail-closes on those rows and on a blocking data.json `gateResult`), so a direct-implementation next-step is an instruction the reader cannot actually follow. **Cross-project pointer rule:** for cross-project dependencies (another repo / a different top-level deployment module / a published package), `crossProjectDependencies` (§5.4 Cross-Project Dependencies) is authoritative — do NOT duplicate that substance (prerequisite work / verification signals / handoff) into `recommendedNextSteps`; put only a one-line pointer to that section (no double-recording).
|
|
335
335
|
4. **Follow-up Tasks** — auto-spawn-eligible table. Each row drives `okstra-spawn-followups.py`; see template §4 for the row schema.
|
|
336
336
|
5. **Missing Information and Risks** — uncertain / "I don't know" items. `implementation-planning` adds §5.5 (see heading contract below); `release-handoff` adds §5.6.
|
|
@@ -56,6 +56,7 @@ from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY, markdown_templat
|
|
|
56
56
|
from okstra_ctl.report_markdown import ReportSections
|
|
57
57
|
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
58
58
|
from okstra_ctl.seeding import installed_version
|
|
59
|
+
from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
TEMPLATE_BY_SCHEMA_VERSION = {
|
|
@@ -84,43 +85,6 @@ class FinalReportRenderError(RuntimeError):
|
|
|
84
85
|
"""
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
def _format_int(value: Any) -> str:
|
|
88
|
-
if value is None or not isinstance(value, (str, int, float)):
|
|
89
|
-
return "--"
|
|
90
|
-
try:
|
|
91
|
-
return f"{int(value):,}"
|
|
92
|
-
except (TypeError, ValueError):
|
|
93
|
-
return "--"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _format_usd(value: Any) -> str:
|
|
97
|
-
if value is None or not isinstance(value, (str, int, float)):
|
|
98
|
-
return "--"
|
|
99
|
-
try:
|
|
100
|
-
return f"${float(value):.2f}"
|
|
101
|
-
except (TypeError, ValueError):
|
|
102
|
-
return "--"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _format_duration_ms(value: Any) -> str:
|
|
106
|
-
if value is None or not isinstance(value, (str, int, float)):
|
|
107
|
-
return "--"
|
|
108
|
-
try:
|
|
109
|
-
ms = int(value)
|
|
110
|
-
except (TypeError, ValueError):
|
|
111
|
-
return "--"
|
|
112
|
-
# A negative elapsed time is nonsensical (clock skew between start/end
|
|
113
|
-
# timestamps); divmod would otherwise produce a malformed "-1m 59s".
|
|
114
|
-
if ms < 0:
|
|
115
|
-
return "--"
|
|
116
|
-
total_seconds = ms // 1000
|
|
117
|
-
hours, remainder = divmod(total_seconds, 3600)
|
|
118
|
-
minutes, seconds = divmod(remainder, 60)
|
|
119
|
-
if hours:
|
|
120
|
-
return f"{hours}h {minutes:02d}m {seconds:02d}s"
|
|
121
|
-
return f"{minutes}m {seconds:02d}s"
|
|
122
|
-
|
|
123
|
-
|
|
124
88
|
def _yaml_scalar(value: Any) -> str:
|
|
125
89
|
"""Serialize a scalar for the YAML frontmatter block.
|
|
126
90
|
|
|
@@ -555,9 +519,9 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
555
519
|
lstrip_blocks=True,
|
|
556
520
|
keep_trailing_newline=True,
|
|
557
521
|
)
|
|
558
|
-
env.filters["format_int"] =
|
|
559
|
-
env.filters["format_usd"] =
|
|
560
|
-
env.filters["format_duration_ms"] =
|
|
522
|
+
env.filters["format_int"] = format_int
|
|
523
|
+
env.filters["format_usd"] = format_usd
|
|
524
|
+
env.filters["format_duration_ms"] = format_duration_ms
|
|
561
525
|
env.filters["yaml_scalar"] = _yaml_scalar
|
|
562
526
|
env.filters["yaml_inline_list"] = _yaml_inline_list
|
|
563
527
|
env.filters["model_detail"] = _model_detail
|
|
@@ -9,7 +9,12 @@ from markupsafe import Markup, escape
|
|
|
9
9
|
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
10
10
|
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
11
11
|
_SENTENCES_PER_PARAGRAPH = 2
|
|
12
|
-
|
|
12
|
+
# `\b` after the digits would end the token only where the next character is
|
|
13
|
+
# non-word, and a Korean particle is a word character — `EA-001에` matched
|
|
14
|
+
# nothing, so every id a translated report cites mid-sentence lost its link
|
|
15
|
+
# while the English source kept it. The boundary a row id actually needs is
|
|
16
|
+
# "not part of a longer alphanumeric run", which is what these assertions say.
|
|
17
|
+
_ID_TOKEN = re.compile(r"(?<![A-Za-z0-9])[A-Z]{1,3}-\d+(?!\d)")
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
def _link_ids(escaped: str, anchors: dict) -> str:
|
|
@@ -14,6 +14,7 @@ from ..final_report_schema import load_schema_for_data, validate
|
|
|
14
14
|
from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
|
|
15
15
|
from ..report_translation import overlay
|
|
16
16
|
from ..report_view_artifacts import user_responses_dir_for_report
|
|
17
|
+
from ..usage_cells import format_duration_ms
|
|
17
18
|
from .common import anchor_index
|
|
18
19
|
from .filters import (
|
|
19
20
|
code_evidence,
|
|
@@ -26,6 +27,7 @@ from .filters import (
|
|
|
26
27
|
from .models import HtmlRunMeta
|
|
27
28
|
from .report_index import inject_report_index
|
|
28
29
|
from .router import HtmlRenderError, resolve_html_route
|
|
30
|
+
from .run_usage import run_usage
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def _sha256(path: Path) -> str:
|
|
@@ -47,17 +49,13 @@ def _elapsed_text(elapsed_ms: int | None) -> str | None:
|
|
|
47
49
|
"""Render a run duration, or nothing when there is none to render.
|
|
48
50
|
|
|
49
51
|
A run whose team-state never recorded timestamps has no measured duration;
|
|
50
|
-
printing "0m" would claim it finished instantly.
|
|
52
|
+
printing "0m" would claim it finished instantly. Measured durations go
|
|
53
|
+
through the same formatter as the per-agent cells below, so the header and
|
|
54
|
+
the run-cost table do not spell one quantity two ways.
|
|
51
55
|
"""
|
|
52
56
|
if not elapsed_ms or elapsed_ms < 0:
|
|
53
57
|
return None
|
|
54
|
-
|
|
55
|
-
hours, minutes = divmod(minutes, 60)
|
|
56
|
-
if hours:
|
|
57
|
-
return f"{hours}h {minutes}m"
|
|
58
|
-
if minutes:
|
|
59
|
-
return f"{minutes}m {seconds}s"
|
|
60
|
-
return f"{seconds}s"
|
|
58
|
+
return format_duration_ms(elapsed_ms)
|
|
61
59
|
|
|
62
60
|
|
|
63
61
|
def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
|
|
@@ -152,6 +150,9 @@ def render_v2_html_view(
|
|
|
152
150
|
"dataSha256": _sha256(data_path),
|
|
153
151
|
"markdownSha256": _sha256(markdown_path),
|
|
154
152
|
"clarificationItems": data.get("clarificationItems", []),
|
|
153
|
+
# Every task type ends with the same run-cost section, so it is bound
|
|
154
|
+
# here rather than in ten view models that would each rebuild it.
|
|
155
|
+
"runUsage": run_usage(data),
|
|
155
156
|
"css": (root / "html/assets/base.css").read_text(encoding="utf-8"),
|
|
156
157
|
"js": response_js + "\n" + base_js,
|
|
157
158
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""What the run cost, per agent — the Phase 7 usage cells as reader-facing text.
|
|
2
|
+
|
|
3
|
+
`executionStatus` is the only block that holds a duration per agent, so the
|
|
4
|
+
table is built from it rather than from `tokenUsage.workerDetails`, which
|
|
5
|
+
repeats the same token figures without saying how long each agent ran.
|
|
6
|
+
|
|
7
|
+
The formatting happens here rather than in the template because a null cell has
|
|
8
|
+
to read as "not measured" everywhere it appears, and `usage_cells` is where both
|
|
9
|
+
report views agree on how that looks.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..usage_cells import format_duration_ms, format_int, format_usd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _number(value: object) -> int | float | None:
|
|
17
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
18
|
+
return None
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _totals_row(row: object) -> dict[str, str]:
|
|
23
|
+
values = row if isinstance(row, dict) else {}
|
|
24
|
+
return {
|
|
25
|
+
"rawTokens": format_int(values.get("totalTokens")),
|
|
26
|
+
"billableTokens": format_int(values.get("billableTokens")),
|
|
27
|
+
"cost": format_usd(values.get("costUsd")),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _agent_row(row: dict) -> dict[str, str]:
|
|
32
|
+
"""One agent's identity and what it spent.
|
|
33
|
+
|
|
34
|
+
The CLI cells stay empty rather than `--` when the agent made no CLI call:
|
|
35
|
+
they render as a second line inside the token and cost cells, and a `--`
|
|
36
|
+
there would read as a missing measurement instead of an absent charge.
|
|
37
|
+
"""
|
|
38
|
+
cli_tokens = _number(row.get("cliTotalTokens")) or 0
|
|
39
|
+
cli_cost = _number(row.get("cliCostUsd")) or 0
|
|
40
|
+
return {
|
|
41
|
+
"agent": str(row.get("agent") or ""),
|
|
42
|
+
"role": str(row.get("role") or ""),
|
|
43
|
+
"model": str(row.get("model") or ""),
|
|
44
|
+
"status": str(row.get("status") or ""),
|
|
45
|
+
"rawTokens": format_int(row.get("totalTokens")),
|
|
46
|
+
"billableTokens": format_int(row.get("billableTokens")),
|
|
47
|
+
"cost": format_usd(row.get("costUsd")),
|
|
48
|
+
"duration": format_duration_ms(row.get("durationMs")),
|
|
49
|
+
"cliTokens": format_int(cli_tokens) if cli_tokens else "",
|
|
50
|
+
"cliCost": format_usd(cli_cost) if cli_cost else "",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sum(rows: list[dict], key: str) -> int | float:
|
|
55
|
+
return sum(_number(row.get(key)) or 0 for row in rows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unaccounted(rows: list[dict], grand: dict) -> dict[str, str] | None:
|
|
59
|
+
"""The part of the total that no row above carries, when there is one.
|
|
60
|
+
|
|
61
|
+
Two documented paths leave a gap. Sessions that match no worker are summed
|
|
62
|
+
into `unattributedWorkerUsage`, and where two report rows share one
|
|
63
|
+
team-state aggregate only the first is attributed, leaving the second's
|
|
64
|
+
cells null. Both land in the total, so without this row the column adds up
|
|
65
|
+
to less than the figure beneath it and neither number can be trusted.
|
|
66
|
+
"""
|
|
67
|
+
grand_tokens = _number(grand.get("totalTokens"))
|
|
68
|
+
if grand_tokens is None:
|
|
69
|
+
return None
|
|
70
|
+
gap = grand_tokens - _sum(rows, "totalTokens")
|
|
71
|
+
if gap <= 0:
|
|
72
|
+
return None
|
|
73
|
+
billable_gap = (_number(grand.get("billableTokens")) or 0) - _sum(rows, "billableTokens")
|
|
74
|
+
cost_gap = (_number(grand.get("costUsd")) or 0) - _sum(rows, "costUsd")
|
|
75
|
+
return {
|
|
76
|
+
"rawTokens": format_int(gap),
|
|
77
|
+
"billableTokens": format_int(max(0, billable_gap)),
|
|
78
|
+
"cost": format_usd(max(0.0, cost_gap)),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_MEASURED_KEYS = ("totalTokens", "billableTokens", "costUsd", "durationMs")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_usage(data: dict) -> dict[str, object] | None:
|
|
86
|
+
"""The run-cost table, or nothing when the run has no measured figure.
|
|
87
|
+
|
|
88
|
+
Phase 7 fills these cells before the HTML is rendered, so an all-null table
|
|
89
|
+
means the collector found no session to read. A grid of `--` states nothing
|
|
90
|
+
the reader can act on, so the section stays out of the document entirely.
|
|
91
|
+
"""
|
|
92
|
+
rows = [row for row in (data.get("executionStatus") or []) if isinstance(row, dict)]
|
|
93
|
+
usage = data.get("tokenUsage") or {}
|
|
94
|
+
totals = {name: _totals_row(usage.get(name)) for name in ("lead", "worker", "grand")}
|
|
95
|
+
measured = any(
|
|
96
|
+
_number(row.get(key)) is not None for row in rows for key in _MEASURED_KEYS
|
|
97
|
+
) or any(
|
|
98
|
+
_number((usage.get(name) or {}).get(key)) is not None
|
|
99
|
+
for name in ("lead", "worker", "grand")
|
|
100
|
+
for key in _MEASURED_KEYS
|
|
101
|
+
)
|
|
102
|
+
if not measured:
|
|
103
|
+
return None
|
|
104
|
+
cli_cost = _number((usage.get("cli") or {}).get("costUsd")) or 0
|
|
105
|
+
return {
|
|
106
|
+
"rows": [_agent_row(row) for row in rows],
|
|
107
|
+
"unaccounted": _unaccounted(rows, usage.get("grand") or {}),
|
|
108
|
+
**totals,
|
|
109
|
+
"cliCost": format_usd(cli_cost) if cli_cost else "",
|
|
110
|
+
}
|
|
@@ -1,32 +1,85 @@
|
|
|
1
1
|
"""Human-first error-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import re
|
|
5
|
+
|
|
4
6
|
from ..common import evidence_index
|
|
5
7
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
8
|
from ..visualizations import cause_graph_figure
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
_LEAD_CLAUSE = re.compile(r"\s[—–-]\s|(?<=[.。!?])\s")
|
|
12
|
+
_MAX_LABEL_CHARS = 44
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _short_label(statement: str) -> str:
|
|
16
|
+
"""The candidate's name, taken from the opening clause of its statement.
|
|
17
|
+
|
|
18
|
+
`causeCandidates` carries no title field: `statement` is the full claim,
|
|
19
|
+
routinely several sentences long. Passed through whole it filled the
|
|
20
|
+
figure's name cell with prose and ran the node text out of the drawing, so
|
|
21
|
+
the figure takes the lead clause and leaves the claim to the cards below.
|
|
22
|
+
"""
|
|
23
|
+
head = _LEAD_CLAUSE.split(statement.strip(), maxsplit=1)[0].strip()
|
|
24
|
+
if len(head) <= _MAX_LABEL_CHARS:
|
|
25
|
+
return head
|
|
26
|
+
return head[:_MAX_LABEL_CHARS].rstrip() + "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cause_node(row: dict, leading_cause_id: str) -> VisualNode:
|
|
30
|
+
# The figure names its nodes and nothing more. Both the failure sentence
|
|
31
|
+
# and each candidate's disproof already have a section of their own, and
|
|
32
|
+
# repeating them here printed every one of them twice.
|
|
33
|
+
leading = row["id"] == leading_cause_id
|
|
34
|
+
return VisualNode(
|
|
35
|
+
row["id"],
|
|
36
|
+
_short_label(row["statement"]),
|
|
37
|
+
"candidate",
|
|
38
|
+
"leading" if leading else row["confidence"],
|
|
39
|
+
"",
|
|
40
|
+
note="Leading cause" if leading else f'Confidence {row["confidence"]}',
|
|
12
41
|
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cause_edges(rows: list[dict], symptom_id: str) -> tuple[VisualEdge, ...]:
|
|
45
|
+
"""The chain between candidates, then the symptom each chain ends at.
|
|
46
|
+
|
|
47
|
+
Candidates are not always competing guesses for one spot. A propagation
|
|
48
|
+
chain — the extraction breaks, the error is swallowed, the empty result is
|
|
49
|
+
stamped a success — needs every link to hold for the symptom to appear, and
|
|
50
|
+
`downstreamOf` is where the diagnosis says so. Drawing every candidate
|
|
51
|
+
straight at the symptom instead claimed they were alternatives.
|
|
52
|
+
|
|
53
|
+
Only a candidate nothing else is downstream of reaches the symptom: an
|
|
54
|
+
upstream link would otherwise be drawn as its own explanation of the
|
|
55
|
+
symptom as well as a step on the way there.
|
|
56
|
+
"""
|
|
57
|
+
known = {row["id"] for row in rows}
|
|
58
|
+
upstream = {
|
|
59
|
+
row["id"]: [step for step in row.get("downstreamOf", []) if step in known]
|
|
60
|
+
for row in rows
|
|
61
|
+
}
|
|
62
|
+
has_downstream = {step for steps in upstream.values() for step in steps}
|
|
63
|
+
chain = tuple(
|
|
64
|
+
VisualEdge(step, row["id"], "then", "chain")
|
|
65
|
+
for row in rows
|
|
66
|
+
for step in upstream[row["id"]]
|
|
23
67
|
)
|
|
24
|
-
|
|
25
|
-
VisualEdge(row
|
|
68
|
+
return chain + tuple(
|
|
69
|
+
VisualEdge(row["id"], symptom_id, "may cause", "hypothesis")
|
|
70
|
+
for row in rows
|
|
71
|
+
if row["id"] not in has_downstream
|
|
26
72
|
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cause_figure(error: dict):
|
|
76
|
+
symptom = VisualNode("symptom", "Observed failure", "effect", "risk", "", note="Symptom")
|
|
77
|
+
rows = error.get("causeCandidates", [])
|
|
78
|
+
leading_cause_id = (error.get("routing") or {}).get("leadingCauseId") or ""
|
|
79
|
+
causes = tuple(_cause_node(row, leading_cause_id) for row in rows)
|
|
27
80
|
return cause_graph_figure(
|
|
28
81
|
nodes=(symptom, *causes),
|
|
29
|
-
edges=
|
|
82
|
+
edges=_cause_edges(rows, symptom.id),
|
|
30
83
|
title="Cause hypotheses and observed symptom",
|
|
31
84
|
)
|
|
32
85
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import html
|
|
5
|
+
import unicodedata
|
|
5
6
|
from dataclasses import replace
|
|
6
7
|
from typing import Iterable, Sequence
|
|
7
8
|
|
|
@@ -11,6 +12,11 @@ from .models import FigureModel, VisualEdge, VisualNode
|
|
|
11
12
|
_ROWS_PER_COLUMN = 8
|
|
12
13
|
_COLUMN_WIDTH = 260
|
|
13
14
|
_ROW_HEIGHT = 100
|
|
15
|
+
_NODE_WIDTH = 180
|
|
16
|
+
_NODE_MIN_HEIGHT = 50
|
|
17
|
+
_LABEL_PADDING = 12
|
|
18
|
+
_LINE_HEIGHT = 16
|
|
19
|
+
_MAX_LABEL_LINES = 3
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
|
|
@@ -141,10 +147,103 @@ _ARROW_MARKER = (
|
|
|
141
147
|
)
|
|
142
148
|
|
|
143
149
|
|
|
150
|
+
def _drawable_text(value: str) -> str:
|
|
151
|
+
"""Strip the markdown code fences a drawing cannot render.
|
|
152
|
+
|
|
153
|
+
Labels carry backticks around identifiers because every other text surface
|
|
154
|
+
turns them into `<code>`. Drawn literally they are stray characters inside
|
|
155
|
+
the box, and they consume width the label needs.
|
|
156
|
+
"""
|
|
157
|
+
return value.replace("`", "")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _text_width(text: str) -> float:
|
|
161
|
+
"""Approximate the advance width of the 13px label font.
|
|
162
|
+
|
|
163
|
+
An SVG carries no font metrics, so wrapping has to estimate. East-Asian
|
|
164
|
+
characters occupy a full em; the rest average a little over half of one.
|
|
165
|
+
"""
|
|
166
|
+
return sum(13.0 if unicodedata.east_asian_width(c) in ("W", "F") else 6.8 for c in text)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _split_oversized_word(word: str, limit: float) -> list[str]:
|
|
170
|
+
"""Cut a word wider than the box into box-width pieces.
|
|
171
|
+
|
|
172
|
+
Korean and Japanese labels arrive as long unbroken runs, and a path or an
|
|
173
|
+
identifier can be wider than the box on its own.
|
|
174
|
+
"""
|
|
175
|
+
pieces: list[str] = []
|
|
176
|
+
current = ""
|
|
177
|
+
for char in word:
|
|
178
|
+
if current and _text_width(current + char) > limit:
|
|
179
|
+
pieces.append(current)
|
|
180
|
+
current = char
|
|
181
|
+
else:
|
|
182
|
+
current += char
|
|
183
|
+
return pieces + [current] if current else pieces
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ellipsize(line: str, limit: float) -> str:
|
|
187
|
+
while line and _text_width(line + "…") > limit:
|
|
188
|
+
line = line[:-1]
|
|
189
|
+
return line + "…"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _wrap_label(label: str) -> list[str]:
|
|
193
|
+
"""Break a label into the lines that fit inside one node box.
|
|
194
|
+
|
|
195
|
+
The label used to be drawn as a single line whatever its length, so a node
|
|
196
|
+
carrying a sentence painted it straight out of the box and off the canvas.
|
|
197
|
+
Text past the last line is cut here rather than drawn, because the
|
|
198
|
+
figure's fallback table prints the label in full.
|
|
199
|
+
"""
|
|
200
|
+
limit = _NODE_WIDTH - 2 * _LABEL_PADDING
|
|
201
|
+
lines: list[str] = []
|
|
202
|
+
current = ""
|
|
203
|
+
for word in label.split():
|
|
204
|
+
pieces = _split_oversized_word(word, limit) if _text_width(word) > limit else [word]
|
|
205
|
+
for piece in pieces:
|
|
206
|
+
candidate = f"{current} {piece}".strip()
|
|
207
|
+
if current and _text_width(candidate) > limit:
|
|
208
|
+
lines.append(current)
|
|
209
|
+
current = piece
|
|
210
|
+
else:
|
|
211
|
+
current = candidate
|
|
212
|
+
if current:
|
|
213
|
+
lines.append(current)
|
|
214
|
+
if len(lines) > _MAX_LABEL_LINES:
|
|
215
|
+
lines = lines[:_MAX_LABEL_LINES]
|
|
216
|
+
lines[-1] = _ellipsize(lines[-1], limit)
|
|
217
|
+
return lines or [""]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _node_group(node: VisualNode, x: int, y: int, lines: Sequence[str], box_height: int) -> str:
|
|
221
|
+
baseline = y + (box_height - len(lines) * _LINE_HEIGHT) // 2 + _LINE_HEIGHT - 4
|
|
222
|
+
spans = "".join(
|
|
223
|
+
f'<tspan x="{x + _LABEL_PADDING}" y="{baseline + index * _LINE_HEIGHT}">'
|
|
224
|
+
f"{html.escape(line)}</tspan>"
|
|
225
|
+
for index, line in enumerate(lines)
|
|
226
|
+
)
|
|
227
|
+
status = html.escape(node.status)
|
|
228
|
+
detail = _drawable_text(node.detail)
|
|
229
|
+
tooltip = f"{_drawable_text(node.label)}: {node.status}." + (f" {detail}" if detail else "")
|
|
230
|
+
return (
|
|
231
|
+
f'<g data-node-id="{html.escape(node.id)}" class="node node-{status}">'
|
|
232
|
+
f"<title>{html.escape(tooltip)}</title>"
|
|
233
|
+
f'<rect x="{x}" y="{y}" width="{_NODE_WIDTH}" height="{box_height}" rx="8"/>'
|
|
234
|
+
f"<text>{spans}</text></g>"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
144
238
|
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
145
239
|
positions = _node_positions(nodes, edges)
|
|
146
|
-
|
|
147
|
-
|
|
240
|
+
wrapped = {node.id: _wrap_label(_drawable_text(node.label)) for node in nodes}
|
|
241
|
+
line_count = max((len(lines) for lines in wrapped.values()), default=1)
|
|
242
|
+
# One height for every box: a figure whose rows are all the same depth
|
|
243
|
+
# keeps the arrow between two columns horizontal.
|
|
244
|
+
box_height = max(_NODE_MIN_HEIGHT, line_count * _LINE_HEIGHT + 2 * _LABEL_PADDING)
|
|
245
|
+
width = max((x for x, _ in positions.values()), default=50) + _NODE_WIDTH + 50
|
|
246
|
+
height = max((y for _, y in positions.values()), default=55) + box_height + 40
|
|
148
247
|
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
149
248
|
parts.append(_ARROW_MARKER)
|
|
150
249
|
for edge in edges:
|
|
@@ -154,8 +253,11 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
154
253
|
x2, y2 = positions[edge.target]
|
|
155
254
|
# Leave the source box on its right edge and arrive on the target's
|
|
156
255
|
# left, so a left-to-right layering reads as one direction of travel.
|
|
157
|
-
|
|
158
|
-
|
|
256
|
+
forward = (x1 + _NODE_WIDTH, x2)
|
|
257
|
+
backward = (x1, x2 + _NODE_WIDTH)
|
|
258
|
+
same_column = (x1 + _NODE_WIDTH // 2, x2 + _NODE_WIDTH // 2)
|
|
259
|
+
start_x, end_x = forward if x2 > x1 else (backward if x2 < x1 else same_column)
|
|
260
|
+
start_y, end_y = y1 + box_height // 2, y2 + box_height // 2
|
|
159
261
|
span = abs(x2 - x1) // _COLUMN_WIDTH
|
|
160
262
|
if span > 1:
|
|
161
263
|
# An edge that skips a column would otherwise be drawn straight
|
|
@@ -177,16 +279,7 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
177
279
|
)
|
|
178
280
|
for node in nodes:
|
|
179
281
|
x, y = positions[node.id]
|
|
180
|
-
|
|
181
|
-
label = html.escape(node.label)
|
|
182
|
-
status = html.escape(node.status)
|
|
183
|
-
detail = html.escape(node.detail)
|
|
184
|
-
parts.append(
|
|
185
|
-
f'<g data-node-id="{node_id}" class="node node-{status}">'
|
|
186
|
-
f"<title>{label}: {status}. {detail}</title>"
|
|
187
|
-
f'<rect x="{x}" y="{y}" width="180" height="50" rx="8"/>'
|
|
188
|
-
f'<text x="{x + 12}" y="{y + 30}">{label}</text></g>'
|
|
189
|
-
)
|
|
282
|
+
parts.append(_node_group(node, x, y, wrapped[node.id], box_height))
|
|
190
283
|
parts.append("</svg>")
|
|
191
284
|
return "".join(parts)
|
|
192
285
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""How a token, cost, or duration figure reads in a report cell.
|
|
2
|
+
|
|
3
|
+
Phase 7 fills the same numbers into two views — the AI-handoff Markdown and
|
|
4
|
+
the reader's HTML — so they format here once. A cell that is still null prints
|
|
5
|
+
``--`` rather than a zero: ``0`` tokens and ``$0.00`` are what a run that spent
|
|
6
|
+
nothing would look like, which is a different claim from "not measured".
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_int(value: Any) -> str:
|
|
14
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
15
|
+
return "--"
|
|
16
|
+
try:
|
|
17
|
+
return f"{int(value):,}"
|
|
18
|
+
except (TypeError, ValueError):
|
|
19
|
+
return "--"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_usd(value: Any) -> str:
|
|
23
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
24
|
+
return "--"
|
|
25
|
+
try:
|
|
26
|
+
return f"${float(value):.2f}"
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return "--"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_duration_ms(value: Any) -> str:
|
|
32
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
33
|
+
return "--"
|
|
34
|
+
try:
|
|
35
|
+
ms = int(value)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return "--"
|
|
38
|
+
# A negative elapsed time is nonsensical (clock skew between start/end
|
|
39
|
+
# timestamps); divmod would otherwise produce a malformed "-1m 59s".
|
|
40
|
+
if ms < 0:
|
|
41
|
+
return "--"
|
|
42
|
+
total_seconds = ms // 1000
|
|
43
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
44
|
+
minutes, seconds = divmod(remainder, 60)
|
|
45
|
+
if hours:
|
|
46
|
+
return f"{hours}h {minutes:02d}m {seconds:02d}s"
|
|
47
|
+
return f"{minutes}m {seconds:02d}s"
|
|
@@ -1637,7 +1637,11 @@
|
|
|
1637
1637
|
"items": { "type": "string" }
|
|
1638
1638
|
},
|
|
1639
1639
|
"confidence": { "enum": ["low", "medium", "high"] },
|
|
1640
|
-
"disproveWith": { "type": "string", "minLength": 1 }
|
|
1640
|
+
"disproveWith": { "type": "string", "minLength": 1 },
|
|
1641
|
+
"downstreamOf": {
|
|
1642
|
+
"type": "array",
|
|
1643
|
+
"items": { "type": "string", "pattern": "^EA-\\d{3,}$" }
|
|
1644
|
+
}
|
|
1641
1645
|
}
|
|
1642
1646
|
}
|
|
1643
1647
|
},
|
|
@@ -38,6 +38,7 @@ svg { max-width: 100%; min-width: 520px; height: auto; }
|
|
|
38
38
|
svg .edge { stroke: GrayText; stroke-width: 2; }
|
|
39
39
|
svg .edge-arrow-head { fill: GrayText; }
|
|
40
40
|
svg .node rect { fill: color-mix(in srgb, Highlight 14%, Canvas); stroke: Highlight; }
|
|
41
|
+
svg .node-leading rect { fill: color-mix(in srgb, Highlight 32%, Canvas); stroke-width: 2; }
|
|
41
42
|
svg .node text { fill: CanvasText; font-size: 13px; }
|
|
42
43
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; font-size: .9rem; }
|
|
43
44
|
/* Korean takes a line break between any two syllables, so an auto-layout
|
|
@@ -79,7 +80,19 @@ th, td { text-align: left; vertical-align: top; border-bottom: 1px solid color-m
|
|
|
79
80
|
.ledger-source { font-size: .9rem; color: GrayText; }
|
|
80
81
|
.ledger-source > span:first-child::after { content: ":"; }
|
|
81
82
|
.status { display: inline-block; white-space: nowrap; border-radius: 999px; padding: .1rem .5rem; background: color-mix(in srgb, GrayText 15%, Canvas); }
|
|
82
|
-
.status-gap, .status-risk { background: color-mix(in srgb, #d94b4b 18%, Canvas); }
|
|
83
|
+
.status-gap, .status-risk, .status-error, .status-timeout { background: color-mix(in srgb, #d94b4b 18%, Canvas); }
|
|
84
|
+
/* Figures are read by comparing them down the column, which only works when
|
|
85
|
+
the digits line up: tabular-nums stops a 1 from being narrower than a 7, and
|
|
86
|
+
the right edge is the one they share. */
|
|
87
|
+
.run-usage-lede { max-width: 78ch; }
|
|
88
|
+
.figure { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
89
|
+
/* Four nowrap number columns need almost none of the width, and the default
|
|
90
|
+
even split spent it on them while the agent names wrapped mid-word. */
|
|
91
|
+
[data-report-section="run-usage"] .row-key { width: 45%; }
|
|
92
|
+
.cli-extra { display: block; color: GrayText; font-size: .85em; }
|
|
93
|
+
tfoot th { font-weight: 600; }
|
|
94
|
+
tfoot tr:first-child > * { border-top: 2px solid color-mix(in srgb, CanvasText 30%, transparent); }
|
|
95
|
+
tfoot .grand-total > * { font-weight: 700; }
|
|
83
96
|
.human-report-footer { width: min(1120px, calc(100% - 2rem)); margin: 0 auto 2rem; display: flex; flex-wrap: wrap; gap: .6rem; }
|
|
84
97
|
.human-report-footer pre { flex-basis: 100%; white-space: pre-wrap; max-height: 14em; overflow: auto; }
|
|
85
98
|
fieldset, label { display: block; margin: .7rem 0; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
{% from "html/macros/forms.html" import clarification_responses %}
|
|
3
|
+
{% from "html/macros/layout.html" import row_key %}
|
|
3
4
|
<html lang="en" data-task-template="{{ taskType }}">
|
|
4
5
|
<head>
|
|
5
6
|
<meta charset="utf-8">
|
|
@@ -38,6 +39,47 @@
|
|
|
38
39
|
</ol>
|
|
39
40
|
</section>
|
|
40
41
|
{% endif %}
|
|
42
|
+
{% if runUsage %}
|
|
43
|
+
<section data-report-section="run-usage" data-reader-kind="audit">
|
|
44
|
+
<h2>{{ t('runUsage.heading') }}</h2>
|
|
45
|
+
<p class="run-usage-lede">{{ t('runUsage.intro') }}</p>
|
|
46
|
+
<table>
|
|
47
|
+
<thead><tr>
|
|
48
|
+
<th scope="col">{{ t('runUsage.agent') }}</th>
|
|
49
|
+
<th scope="col" class="figure">{{ t('runUsage.raw-tokens') }}</th>
|
|
50
|
+
<th scope="col" class="figure">{{ t('runUsage.billable-tokens') }}</th>
|
|
51
|
+
<th scope="col" class="figure">{{ t('runUsage.cost') }}</th>
|
|
52
|
+
<th scope="col" class="figure">{{ t('runUsage.duration') }}</th>
|
|
53
|
+
</tr></thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
{% for row in runUsage.rows %}
|
|
56
|
+
<tr>
|
|
57
|
+
{{ row_key(pairs=[(t('runUsage.agent'), row.agent), (t('runUsage.role'), row.role), (t('runUsage.model'), row.model)], status_name=t('runUsage.status'), status_raw=row.status, status_text=row.status | enum_label('workerStatus')) }}
|
|
58
|
+
<td class="figure">{{ row.rawTokens }}{% if row.cliTokens %}<span class="cli-extra">{{ t('runUsage.cli') }} {{ row.cliTokens }}</span>{% endif %}</td>
|
|
59
|
+
<td class="figure">{{ row.billableTokens }}</td>
|
|
60
|
+
<td class="figure">{{ row.cost }}{% if row.cliCost %}<span class="cli-extra">+ {{ t('runUsage.cli') }} {{ row.cliCost }}</span>{% endif %}</td>
|
|
61
|
+
<td class="figure">{{ row.duration }}</td>
|
|
62
|
+
</tr>
|
|
63
|
+
{% endfor %}
|
|
64
|
+
{% if runUsage.unaccounted %}
|
|
65
|
+
<tr>
|
|
66
|
+
<td class="row-key">{{ t('runUsage.unaccounted') }}</td>
|
|
67
|
+
<td class="figure">{{ runUsage.unaccounted.rawTokens }}</td>
|
|
68
|
+
<td class="figure">{{ runUsage.unaccounted.billableTokens }}</td>
|
|
69
|
+
<td class="figure">{{ runUsage.unaccounted.cost }}</td>
|
|
70
|
+
<td class="figure"></td>
|
|
71
|
+
</tr>
|
|
72
|
+
{% endif %}
|
|
73
|
+
</tbody>
|
|
74
|
+
<tfoot>
|
|
75
|
+
<tr><th scope="row">{{ t('runUsage.row-lead') }}</th><td class="figure">{{ runUsage.lead.rawTokens }}</td><td class="figure">{{ runUsage.lead.billableTokens }}</td><td class="figure">{{ runUsage.lead.cost }}</td><td class="figure"></td></tr>
|
|
76
|
+
<tr><th scope="row">{{ t('runUsage.row-workers') }}</th><td class="figure">{{ runUsage.worker.rawTokens }}</td><td class="figure">{{ runUsage.worker.billableTokens }}</td><td class="figure">{{ runUsage.worker.cost }}</td><td class="figure"></td></tr>
|
|
77
|
+
<tr class="grand-total"><th scope="row">{{ t('runUsage.row-total') }}</th><td class="figure">{{ runUsage.grand.rawTokens }}</td><td class="figure">{{ runUsage.grand.billableTokens }}</td><td class="figure">{{ runUsage.grand.cost }}</td><td class="figure"></td></tr>
|
|
78
|
+
{% if runUsage.cliCost %}<tr><th scope="row">{{ t('runUsage.row-cli') }}</th><td class="figure"></td><td class="figure"></td><td class="figure">{{ runUsage.cliCost }}</td><td class="figure"></td></tr>{% endif %}
|
|
79
|
+
</tfoot>
|
|
80
|
+
</table>
|
|
81
|
+
</section>
|
|
82
|
+
{% endif %}
|
|
41
83
|
</main>
|
|
42
84
|
<footer class="human-report-footer">
|
|
43
85
|
<button type="button" data-action="export-user-response">{{ t('base.export-my-answers') }}</button>
|
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
"handoffMode": {
|
|
61
61
|
"whole-task": "Whole task",
|
|
62
62
|
"stage-group": "Selected stages"
|
|
63
|
+
},
|
|
64
|
+
"workerStatus": {
|
|
65
|
+
"completed": "Completed",
|
|
66
|
+
"error": "Failed",
|
|
67
|
+
"timeout": "Timed out",
|
|
68
|
+
"not-run": "Not run",
|
|
69
|
+
"synthesis-only": "Synthesis only"
|
|
63
70
|
}
|
|
64
71
|
},
|
|
65
72
|
"enumHint": {
|
|
@@ -86,6 +93,24 @@
|
|
|
86
93
|
"drop-that-file-into": ". Drop that file into",
|
|
87
94
|
"and-the-next-run-picks-your-answers-up-on-it": "and the next run picks your answers up on its own."
|
|
88
95
|
},
|
|
96
|
+
"runUsage": {
|
|
97
|
+
"heading": "What this run cost",
|
|
98
|
+
"intro": "Every agent this run dispatched, with what it spent and how long it was working. Agents run alongside each other, so their durations do not add up to the elapsed time in the header. Raw tokens are the volume processed; billable tokens are that same work restated in input-price units, which is what the cost is computed from.",
|
|
99
|
+
"agent": "Agent",
|
|
100
|
+
"role": "Role",
|
|
101
|
+
"model": "Model",
|
|
102
|
+
"status": "Status",
|
|
103
|
+
"raw-tokens": "Raw tokens",
|
|
104
|
+
"billable-tokens": "Billable tokens",
|
|
105
|
+
"cost": "Cost",
|
|
106
|
+
"duration": "Working time",
|
|
107
|
+
"cli": "CLI",
|
|
108
|
+
"unaccounted": "Not attributed to an agent above",
|
|
109
|
+
"row-lead": "Lead",
|
|
110
|
+
"row-workers": "Workers",
|
|
111
|
+
"row-total": "Total",
|
|
112
|
+
"row-cli": "CLI calls (billed separately)"
|
|
113
|
+
},
|
|
89
114
|
"macros": {
|
|
90
115
|
"forms": {
|
|
91
116
|
"review-this-analysis": "Review this analysis",
|
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
"handoffMode": {
|
|
61
61
|
"whole-task": "태스크 전체",
|
|
62
62
|
"stage-group": "선택한 stage"
|
|
63
|
+
},
|
|
64
|
+
"workerStatus": {
|
|
65
|
+
"completed": "완료",
|
|
66
|
+
"error": "실패",
|
|
67
|
+
"timeout": "시간 초과",
|
|
68
|
+
"not-run": "미실행",
|
|
69
|
+
"synthesis-only": "종합만 수행"
|
|
63
70
|
}
|
|
64
71
|
},
|
|
65
72
|
"enumHint": {
|
|
@@ -86,6 +93,24 @@
|
|
|
86
93
|
"drop-that-file-into": ". 그 파일을 여기에 두면",
|
|
87
94
|
"and-the-next-run-picks-your-answers-up-on-it": "다음 run 이 알아서 답변을 읽어 갑니다."
|
|
88
95
|
},
|
|
96
|
+
"runUsage": {
|
|
97
|
+
"heading": "이번 실행에 든 비용",
|
|
98
|
+
"intro": "이번 실행이 투입한 에이전트별로 얼마를 썼고 얼마나 오래 일했는지입니다. 에이전트는 서로 겹쳐서 돌기 때문에 각 작업 시간을 더해도 머리말의 소요 시간과 같지 않습니다. 원시 토큰은 처리한 분량이고, 과금 환산 토큰은 같은 작업을 입력 단가 기준으로 환산한 값으로 금액은 이 값에서 나옵니다.",
|
|
99
|
+
"agent": "에이전트",
|
|
100
|
+
"role": "역할",
|
|
101
|
+
"model": "모델",
|
|
102
|
+
"status": "상태",
|
|
103
|
+
"raw-tokens": "원시 토큰",
|
|
104
|
+
"billable-tokens": "과금 환산 토큰",
|
|
105
|
+
"cost": "금액",
|
|
106
|
+
"duration": "작업 시간",
|
|
107
|
+
"cli": "CLI",
|
|
108
|
+
"unaccounted": "위 행에 귀속되지 않은 사용량",
|
|
109
|
+
"row-lead": "리드",
|
|
110
|
+
"row-workers": "워커 합계",
|
|
111
|
+
"row-total": "합계",
|
|
112
|
+
"row-cli": "CLI 호출 (별도 청구)"
|
|
113
|
+
},
|
|
89
114
|
"macros": {
|
|
90
115
|
"forms": {
|
|
91
116
|
"review-this-analysis": "이 분석 검토하기",
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
</figcaption>
|
|
8
8
|
<div class="visualization" aria-hidden="true">{{ model.svg | safe }}</div>
|
|
9
9
|
{% set show_paths = model.nodes | selectattr("paths") | first is defined %}
|
|
10
|
+
{% set show_detail = model.nodes | selectattr("detail") | first is defined %}
|
|
10
11
|
<table class="visualization-fallback">
|
|
11
|
-
<thead><tr><th>{{ t('macros.visualizations.component') }}</th
|
|
12
|
+
<thead><tr><th>{{ t('macros.visualizations.component') }}</th>{% if show_detail %}<th>{{ t('macros.visualizations.what-it-does') }}</th>{% endif %}{% if show_paths %}<th>{{ t('macros.visualizations.paths') }}</th>{% endif %}</tr></thead>
|
|
12
13
|
<tbody>
|
|
13
14
|
{% for node in model.nodes %}
|
|
14
|
-
<tr{% if anchor_nodes %} id="id-{{ node.id }}"{% endif %} data-fallback-id="{{ node.id }}">{{ row_key(pairs=[("ID", node.id), ("Name", node.label), ("Kind", node.note)]) }}<td>{{ node.detail | inline_code }}</td>{% if show_paths %}<td>{% for path in node.paths %}<code>{{ path }}</code>{% if not loop.last %} {% endif %}{% endfor %}</td>{% endif %}</tr>
|
|
15
|
+
<tr{% if anchor_nodes %} id="id-{{ node.id }}"{% endif %} data-fallback-id="{{ node.id }}">{{ row_key(pairs=[("ID", node.id), ("Name", node.label), ("Kind", node.note)]) }}{% if show_detail %}<td>{{ node.detail | inline_code }}</td>{% endif %}{% if show_paths %}<td>{% for path in node.paths %}<code>{{ path }}</code>{% if not loop.last %} {% endif %}{% endfor %}</td>{% endif %}</tr>
|
|
15
16
|
{% endfor %}
|
|
16
17
|
</tbody>
|
|
17
18
|
</table>
|
|
@@ -2972,6 +2972,68 @@ def _route_target_matches(value: Any, target: str, *, command: bool) -> bool:
|
|
|
2972
2972
|
)
|
|
2973
2973
|
|
|
2974
2974
|
|
|
2975
|
+
def _upstream_by_candidate(candidates: list[Any]) -> dict[str, list[str]]:
|
|
2976
|
+
upstream: dict[str, list[str]] = {}
|
|
2977
|
+
for candidate in candidates:
|
|
2978
|
+
if not isinstance(candidate, Mapping):
|
|
2979
|
+
continue
|
|
2980
|
+
candidate_id = candidate.get("id")
|
|
2981
|
+
declared = candidate.get("downstreamOf")
|
|
2982
|
+
if isinstance(candidate_id, str) and isinstance(declared, list):
|
|
2983
|
+
upstream[candidate_id] = [row for row in declared if isinstance(row, str)]
|
|
2984
|
+
return upstream
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
def _chain_cycle(upstream: dict[str, list[str]]) -> list[str]:
|
|
2988
|
+
"""The first cycle reachable through `downstreamOf`, as the ids that form it.
|
|
2989
|
+
|
|
2990
|
+
A cycle is a diagnosis that says each step is caused by the next, so it
|
|
2991
|
+
names no first cause. It also hangs the figure's layering, which relaxes
|
|
2992
|
+
until depths settle.
|
|
2993
|
+
"""
|
|
2994
|
+
settled: set[str] = set()
|
|
2995
|
+
for start in sorted(upstream):
|
|
2996
|
+
stack = [start]
|
|
2997
|
+
on_path: list[str] = []
|
|
2998
|
+
while stack:
|
|
2999
|
+
current = stack.pop()
|
|
3000
|
+
if current in on_path:
|
|
3001
|
+
return on_path[on_path.index(current):] + [current]
|
|
3002
|
+
if current in settled or current not in upstream:
|
|
3003
|
+
continue
|
|
3004
|
+
on_path.append(current)
|
|
3005
|
+
stack.extend(upstream[current])
|
|
3006
|
+
settled.update(on_path)
|
|
3007
|
+
return []
|
|
3008
|
+
|
|
3009
|
+
|
|
3010
|
+
def _validate_cause_chain(
|
|
3011
|
+
candidates: list[Any], candidate_ids: set[str], failures: list[str]
|
|
3012
|
+
) -> None:
|
|
3013
|
+
"""`downstreamOf` must name a sibling candidate, and never itself."""
|
|
3014
|
+
upstream = _upstream_by_candidate(candidates)
|
|
3015
|
+
for candidate_id in sorted(upstream):
|
|
3016
|
+
unknown = sorted(set(upstream[candidate_id]) - candidate_ids)
|
|
3017
|
+
if unknown:
|
|
3018
|
+
failures.append(
|
|
3019
|
+
f"final-report data.json: {candidate_id}.downstreamOf names "
|
|
3020
|
+
"unknown cause candidate(s): " + ", ".join(unknown) + "."
|
|
3021
|
+
)
|
|
3022
|
+
if candidate_id in upstream[candidate_id]:
|
|
3023
|
+
failures.append(
|
|
3024
|
+
f"final-report data.json: {candidate_id}.downstreamOf names itself."
|
|
3025
|
+
)
|
|
3026
|
+
cycle = _chain_cycle(
|
|
3027
|
+
{key: [row for row in value if row in candidate_ids] for key, value in upstream.items()}
|
|
3028
|
+
)
|
|
3029
|
+
if cycle:
|
|
3030
|
+
failures.append(
|
|
3031
|
+
"final-report data.json: cause candidates form a downstreamOf cycle: "
|
|
3032
|
+
+ " -> ".join(cycle)
|
|
3033
|
+
+ "."
|
|
3034
|
+
)
|
|
3035
|
+
|
|
3036
|
+
|
|
2975
3037
|
def _validate_error_analysis_consistency(
|
|
2976
3038
|
data: Mapping[str, Any], failures: list[str]
|
|
2977
3039
|
) -> None:
|
|
@@ -3018,6 +3080,8 @@ def _validate_error_analysis_consistency(
|
|
|
3018
3080
|
+ "."
|
|
3019
3081
|
)
|
|
3020
3082
|
|
|
3083
|
+
_validate_cause_chain(candidates, set(candidate_ids), failures)
|
|
3084
|
+
|
|
3021
3085
|
routing_value = error_analysis.get("routing")
|
|
3022
3086
|
routing = routing_value if isinstance(routing_value, Mapping) else {}
|
|
3023
3087
|
target = routing.get("nextTaskType")
|