diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf49efb --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.playwright-mcp/ +.worktrees/ +prompt.txt + +# Local portable tool payloads and downloads. Keep small wrappers/scripts only. +.tools/git/ +.tools/gh/ +.tools/downloads/ + +# Local downloads, caches, and generated report runs. +.download_wferp_*/ +.download_wferp_skill_*/ +.opencode/ +.pytest-tmp/ +.tmp/ +.wferp-llm-temp/ +pytest-cache-files-*/ +wferp-report-runs/ + +# Runtime artifacts. +*.sqlite3 +*.db +*.log diff --git a/.opencode/commands/phase1-start.md b/.opencode/commands/phase1-start.md new file mode 100644 index 0000000..3770fbe --- /dev/null +++ b/.opencode/commands/phase1-start.md @@ -0,0 +1,15 @@ +--- +description: Start the Phase 1 orchestrator checkpoint-to-new-session flow for a selected AFK issue packet +agent: build +subtask: false +--- + +Run the Phase 1 orchestrator checkpoint-to-new-session runner for issue packet `$ARGUMENTS`. + +1. Execute: +!`python3 scripts/phase1_orchestrator_runner.py --issue-packet $ARGUMENTS` +2. Read `docs/agents/runtime/context-checkpoint.yaml` after the runner updates it. +3. Read `.opencode/runtime/new-session-request.json` after the runner writes it. +4. Tell me the checkpoint update result, the continuation request path, and the immediate next action. + +Do not start worker execution yet. Stop after reporting the runner output and the generated continuation request. diff --git a/.opencode/commands/show-last-root-session.md b/.opencode/commands/show-last-root-session.md new file mode 100644 index 0000000..d62f0c3 --- /dev/null +++ b/.opencode/commands/show-last-root-session.md @@ -0,0 +1,26 @@ +--- +description: Show the latest continuation root session and how to open it +agent: build +subtask: false +--- + +Read `.opencode/runtime/new-session-result.json`. + +Report these fields when present: +- `status` +- `title` +- `reason` +- `sourceSessionID` +- `rootSessionID` +- `recordedAt` +- `tuiResumeCommand` +- `cliOpenCommand` +- `recommendedAction` +- `error` + +If `status` is `success`, tell me exactly how to inspect the root session: +1. In OpenCode TUI, run `/sessions` and switch to `rootSessionID`. +2. Or run `cliOpenCommand` from a shell. +3. In this repo's OpenCode TUI, run `/open-last-root-session` to jump directly to `rootSessionID`. + +If `status` is `error`, explain that no root session is available yet and include the recorded error. diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000..968afa0 --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,9 @@ +{ + "plugin": [ + "./plugins/session-continuation.ts", + "./plugins/session-continuation-tui.ts" + ], + "dependencies": { + "@opencode-ai/plugin": "1.14.33" + } +} diff --git a/.opencode/plugins/session-continuation-tui.ts b/.opencode/plugins/session-continuation-tui.ts new file mode 100644 index 0000000..97a0c3b --- /dev/null +++ b/.opencode/plugins/session-continuation-tui.ts @@ -0,0 +1,67 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" + +import type { TuiPluginModule } from "@opencode-ai/plugin/dist/tui" + +type NewSessionResult = { + status?: "success" | "error" + rootSessionID?: string + title?: string + error?: string +} + +const NEW_SESSION_RESULT_RELATIVE_PATH = ".opencode/runtime/new-session-result.json" + +async function readLatestSessionResult(worktree: string): Promise { + try { + const resultText = await readFile(join(worktree, NEW_SESSION_RESULT_RELATIVE_PATH), "utf-8") + return JSON.parse(resultText) as NewSessionResult + } catch { + return undefined + } +} + +const SessionContinuationTuiPlugin: TuiPluginModule = { + tui: async (api) => { + api.command.register(() => [ + { + title: "Open last root session", + value: "open-last-root-session", + description: "Open the latest continuation root session recorded by the session continuation plugin.", + category: "Session", + slash: { + name: "open-last-root-session", + }, + onSelect: async () => { + const result = await readLatestSessionResult(api.state.path.worktree) + if (!result) { + api.ui.toast({ + variant: "error", + title: "No continuation result", + message: "No .opencode/runtime/new-session-result.json file was found.", + }) + return + } + if (result.status !== "success" || !result.rootSessionID) { + api.ui.toast({ + variant: "error", + title: "Root session unavailable", + message: result.error ?? "The latest continuation result does not contain a root session.", + }) + return + } + api.route.navigate("session", { sessionID: result.rootSessionID }) + api.ui.toast({ + variant: "success", + title: "Opened root session", + message: result.title + ? `Switched to ${result.rootSessionID} (${result.title}).` + : `Switched to ${result.rootSessionID}.`, + }) + }, + }, + ]) + }, +} + +export default SessionContinuationTuiPlugin diff --git a/.opencode/plugins/session-continuation.ts b/.opencode/plugins/session-continuation.ts new file mode 100644 index 0000000..0c09f72 --- /dev/null +++ b/.opencode/plugins/session-continuation.ts @@ -0,0 +1,175 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +import { type Plugin, tool } from "@opencode-ai/plugin" + +const NEW_SESSION_REQUEST_RELATIVE_PATH = ".opencode/runtime/new-session-request.json" +const NEW_SESSION_RESULT_RELATIVE_PATH = ".opencode/runtime/new-session-result.json" + +type NewSessionRequest = { + reason?: string + title?: string + agent?: string + prompt?: string +} + +type NewSessionResult = { + status: "success" | "error" + sourceSessionID: string + rootSessionID?: string + title: string + reason?: string + error?: string + tuiResumeCommand?: string + cliOpenCommand?: string + recommendedAction?: string + recordedAt: string +} + +const SAFE_PRIMARY_AGENTS = new Set(["build", "plan"]) + +async function consumeNewSessionRequest(worktree: string): Promise { + const requestPath = join(worktree, NEW_SESSION_REQUEST_RELATIVE_PATH) + try { + const requestText = await readFile(requestPath, "utf-8") + await rm(requestPath, { force: true }) + return JSON.parse(requestText) + } catch { + return undefined + } +} + +async function writeNewSessionResult(worktree: string, result: NewSessionResult): Promise { + const resultPath = join(worktree, NEW_SESSION_RESULT_RELATIVE_PATH) + await mkdir(join(worktree, ".opencode/runtime"), { recursive: true }) + await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8") +} + +function buildNewSessionPrompt(request: NewSessionRequest): string { + return ( + request.prompt ?? + [ + "Resume the autonomous Workflow ERP development workflow from checkpoint only.", + "Read docs/agents/runtime/context-checkpoint.yaml first.", + "Do not import or rely on the prior session transcript.", + "Use compact_payload.authoritative_refs and compact_payload.immediate_next_action as the source of truth.", + "Continue with the next workflow action, preserving repo role boundaries and checkpoint-only resume policy.", + ].join("\n") + ) +} + +function resolveBootstrapAgent(request: NewSessionRequest): string { + if (!request.agent) return "build" + const normalizedAgent = request.agent.trim().toLowerCase() + return SAFE_PRIMARY_AGENTS.has(normalizedAgent) ? normalizedAgent : "build" +} + +async function continueInNewSession( + client: Parameters[0]["client"], + directory: string, + request: NewSessionRequest, +): Promise<{ rootSessionID: string; title: string }> { + const title = request.title ?? "Continue workflow from checkpoint" + const created = await client.session.create({ + query: { directory }, + body: { + title, + }, + }) + if (created.error) { + throw new Error(`OpenCode session.create failed: ${String(created.error)}`) + } + + const rootSessionID = created.data?.id + if (!rootSessionID) { + throw new Error("OpenCode session.create did not return a root session id") + } + + const promptResult = await client.session.promptAsync({ + path: { id: rootSessionID }, + query: { directory }, + body: { + agent: resolveBootstrapAgent(request), + parts: [ + { + type: "text", + text: buildNewSessionPrompt(request), + }, + ], + }, + }) + if (promptResult.error) { + throw new Error(`OpenCode session.promptAsync failed: ${String(promptResult.error)}`) + } + + return { rootSessionID, title } +} + +export const SessionContinuationPlugin: Plugin = async ({ client, directory, worktree }) => { + return { + event: async ({ event }) => { + if (event.type !== "session.idle") return + + const newSessionRequest = await consumeNewSessionRequest(worktree) + if (!newSessionRequest) return + + try { + const result = await continueInNewSession(client, directory, newSessionRequest) + await writeNewSessionResult(worktree, { + status: "success", + sourceSessionID: event.properties.sessionID, + rootSessionID: result.rootSessionID, + title: result.title, + reason: newSessionRequest.reason, + tuiResumeCommand: "/sessions", + cliOpenCommand: `opencode --session ${result.rootSessionID}`, + recommendedAction: + `Open /sessions in OpenCode TUI and switch to ${result.rootSessionID}, ` + + `or run opencode --session ${result.rootSessionID}.`, + recordedAt: new Date().toISOString(), + }) + } catch (error) { + await writeNewSessionResult(worktree, { + status: "error", + sourceSessionID: event.properties.sessionID, + title: newSessionRequest.title ?? "Continue workflow from checkpoint", + reason: newSessionRequest.reason, + error: error instanceof Error ? error.message : String(error), + recordedAt: new Date().toISOString(), + }) + } + }, + tool: { + continue_in_new_session: tool({ + description: + "Create a fresh OpenCode root session and prompt it to resume this repo workflow from docs/agents/runtime/context-checkpoint.yaml.", + args: { + reason: tool.schema.string().optional().describe("Why a fresh continuation session is being created."), + title: tool.schema.string().optional().describe("Optional title for the root session."), + agent: tool.schema.string().optional().describe("Optional OpenCode agent name for the bootstrap prompt."), + prompt: tool.schema.string().optional().describe("Optional custom bootstrap prompt."), + }, + async execute(args, context) { + context.metadata({ + title: "Continue in new session", + metadata: { + sessionID: context.sessionID, + reason: args.reason ?? "not specified", + }, + }) + + const result = await continueInNewSession(client, context.directory, args) + + return { + output: `Created root continuation session ${result.rootSessionID} from ${context.sessionID}.`, + metadata: { + sourceSessionID: context.sessionID, + rootSessionID: result.rootSessionID, + title: result.title, + }, + } + }, + }), + }, + } +} diff --git a/.tools/activate.cmd b/.tools/activate.cmd new file mode 100644 index 0000000..8f98783 --- /dev/null +++ b/.tools/activate.cmd @@ -0,0 +1,9 @@ +@echo off +chcp 65001 >nul +set "PYTHONUTF8=1" +set "PYTHONIOENCODING=utf-8" +set "TOOL_BIN=%~dp0bin" +set "CODEX_PYTHON=C:\Users\ivychi\.cache\codex-runtimes\codex-primary-runtime\dependencies\python" +set "NODE_HOME=C:\Users\ivychi\util\nodejs" +set "PATH=%TOOL_BIN%;%CODEX_PYTHON%;%NODE_HOME%;%PATH%" +echo WFERP local tools are active for this cmd session. diff --git a/.tools/activate.ps1 b/.tools/activate.ps1 new file mode 100644 index 0000000..de23c85 --- /dev/null +++ b/.tools/activate.ps1 @@ -0,0 +1,26 @@ +$RepoRoot = Split-Path -Parent $PSScriptRoot + +chcp 65001 > $null +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" + +$Paths = @( + (Join-Path $PSScriptRoot "bin"), + "C:\Users\ivychi\.cache\codex-runtimes\codex-primary-runtime\dependencies\python", + "C:\Users\ivychi\util\nodejs" +) + +$PathsToAdd = $Paths.Clone() +[array]::Reverse($PathsToAdd) + +foreach ($Path in $PathsToAdd) { + if ((Test-Path $Path) -and (($env:Path -split ";") -notcontains $Path)) { + $env:Path = "$Path;$env:Path" + } +} + +Write-Host "WFERP local tools are active for this PowerShell session." +Write-Host "Repo: $RepoRoot" diff --git a/.tools/bin/gh.cmd b/.tools/bin/gh.cmd new file mode 100644 index 0000000..496cdb9 --- /dev/null +++ b/.tools/bin/gh.cmd @@ -0,0 +1,2 @@ +@echo off +"%~dp0..\gh\gh.exe" %* diff --git a/.tools/bin/git.cmd b/.tools/bin/git.cmd new file mode 100644 index 0000000..ec34dd1 --- /dev/null +++ b/.tools/bin/git.cmd @@ -0,0 +1,2 @@ +@echo off +"%~dp0..\git\cmd\git.exe" %* diff --git a/.tools/bin/pytest.cmd b/.tools/bin/pytest.cmd new file mode 100644 index 0000000..7ec3937 --- /dev/null +++ b/.tools/bin/pytest.cmd @@ -0,0 +1,7 @@ +@echo off +set "CODEX_PYTHON=C:\Users\ivychi\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if exist "%CODEX_PYTHON%" ( + "%CODEX_PYTHON%" -m pytest %* +) else ( + py -3 -m pytest %* +) diff --git a/.tools/bin/python.cmd b/.tools/bin/python.cmd new file mode 100644 index 0000000..3286b9d --- /dev/null +++ b/.tools/bin/python.cmd @@ -0,0 +1,7 @@ +@echo off +set "CODEX_PYTHON=C:\Users\ivychi\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +if exist "%CODEX_PYTHON%" ( + "%CODEX_PYTHON%" %* +) else ( + py -3 %* +) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6290e0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,223 @@ +# AGENTS.md + +## Repo At A Glance +- Repo root is `/home/timmypai/apps/wferp`; some docs still call this checkout `schema/`. +- This repo has two coupled surfaces: static Workflow ERP schema docs and newer natural-language-to-SQL tooling. +- Published static artifacts live at root: `index.html`, `df_style.css`, `HTML/`. +- Canonical schema metadata artifacts live in `_Source/` and are consumed by both HTML generation and `skill_scripts/`. + +## High-Value Structure +- `_Source/1_mssql_to_json.py` extracts ERP metadata from MSSQL `DSCSYS` tables into JSON. +- `_Source/2_FieldNameConvert2utf8.py` repairs mojibake (`iso-8859-1` -> Big5) and enriches Vietnamese names from `language.json`. +- `_Source/3_CreateIndexHtml.py` and `_Source/4_CreateTableStructureHtml.py` generate `index.html` and `HTML/`. +- `_Source/5_CreateTableStructureSQL.py` optionally emits table SQL files under `SQL/`. +- `skill_scripts/` is the current SQL-generation and validation stack; `skill_scripts/cli_generate_select.py` is the CLI entrypoint. +- `tests/skill_scripts/` is the pytest suite for SQL tooling; `test_db/` is the SQL Server execution-validation environment. +- `skills/workflow-erp-sql-generator/` contains the agent-facing SQL-generation skill and references. + +## Setup +- There is no checked-in dependency manifest. Install the verified Python dependencies manually: + +```bash +python3 -m pip install pymssql pandas pytest +``` + +- `pyodbc` is optional and only needed for the `DB_DRIVER=pyodbc` path in `skill_scripts/database_client.py`. +- On this Windows workstation, repo-local portable tools live under `.tools/`. + Activate them before running shell workflows so `git`, `gh`, `python`, and + `pytest` resolve without global installation: + +```powershell +. .\.tools\activate.ps1 +``` + +```cmd +.tools\activate.cmd +``` + +## Windows UTF-8 and Payload Safety +- On Windows, PowerShell display settings are not enough to protect generated + files. Always activate `.tools/activate.ps1` or `.tools/activate.cmd` before + report-harness work so the console, `$OutputEncoding`, and Python I/O use + UTF-8. +- Do not pass Chinese prompt text, workbook summaries, report JSON, HTML, or + checkpoint payloads through PowerShell command-line arguments, here-strings, + or pipelines. PowerShell code page or `$OutputEncoding` drift can replace + characters with literal `?` before Python or Node receives the data. +- Pass large or multilingual payloads by UTF-8 files and pass only ASCII file + paths/flags on the command line, for example + `--classification-payload payload.json`. +- After writing checkpoint, plan, source, data, validator, or state artifacts, + run `python -m skill_scripts.cli_report_harness check-run-text-artifacts + --run-dir ` before opening the Visual Companion or advancing a gate. + Any `repeated_question_marks`, `mojibake_marker`, or UTF-8 decode failure is + blocking. + +## Autonomous Workflow Role Priority +- When the user asks to follow the autonomous development workflow, `docs/agents/autonomous-development-workflow.yaml` is the active repo operating contract for that unit of work. +- In that mode, the main agent is an orchestrator only. It may select issues, build issue packets, spawn worker/verifier sessions, validate returned artifact schemas, update handoff metadata, and publish PR/issue summaries. +- Generic agent requirements to personally run diagnostics, tests, builds, browser checks, SQL execution validation, or manual QA are satisfied by fresh worker/verifier artifacts, not by direct main-agent execution. +- Worker sessions may run implementation self-checks needed to build the change. Final acceptance QA, rerun checks, and surface validation belong to a fresh verifier worker and must be represented by an evidence packet. +- If these role boundaries conflict with generic agent instructions or tool expectations, the main agent must stop and report a policy conflict instead of running QA directly. +- Contract validation means checking worker result/evidence packet shape, links, acceptance-criteria coverage, and proof of separation. It does not include running `pytest`, `py_compile`, `lsp_diagnostics` on issue code, browser automation, SQL execution validation, manual drivers, or `review-work` QA from the main agent. + +## Legacy Static-Doc Regeneration +- Under autonomous workflow, these commands are worker/verifier references. The main agent must delegate execution and only validate the resulting artifacts. +- Run from `_Source/`; these scripts use relative paths and will read/write the wrong locations from repo root. +- Before step 1, edit `_Source/1_mssql_to_json.py` credentials: `SERVER_IP`, `USERNAME`, `PASSWORD`, `DATABASE`. + +```bash +python3 1_mssql_to_json.py +python3 2_FieldNameConvert2utf8.py +python3 3_CreateIndexHtml.py +python3 4_CreateTableStructureHtml.py +# optional +python3 5_CreateTableStructureSQL.py +``` + +- Static-doc verification: open `index.html` in a browser, click several modules, and confirm iframe pages under `HTML/` load. + +## SQL Tooling Commands +- Under autonomous workflow, these commands are worker/verifier references. The main agent must not run them as final verification. +- Build derived artifacts after changing `_Source/` JSON, schema loading, dictionary, or relationship logic: + +```bash +python3 -m skill_scripts.cli_generate_select --build-artifacts +``` + +- Generate SQL from repo root. Default mode is `llm-first`; use `--mode rule` for deterministic behavior. + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" +python3 -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" --mode rule +pytest tests/skill_scripts/ -v +pytest tests/skill_scripts/test_schema_loader.py -v +``` + +## Pytest on Windows +- Keep `pytest.ini` with `addopts = -p no:cacheprovider`. On this workstation, pytest's cache provider can create unreadable `pytest-cache-files-*` temp directories because of Windows/domain ACL behavior. +- Keep the Windows workaround in `tests/conftest.py`. Pytest's built-in `tmp_path` flow calls `Path.mkdir(mode=0o700)` for `--basetemp` and per-test temp folders; on this machine that mode creates directories the current user cannot scan or delete, causing `WinError 5` during fixture setup or session cleanup. +- Do not "fix" pytest by deleting or bypassing `tmp_path` tests. The repo-level fix converts Windows-only `mode=0o700` temp mkdir calls to `0o777` during tests. +- Prefer running pytest through the bundled/runtime Python already used by Codex when PATH is uncertain. +- If stale `.pytest-*`, `.pytest_cache`, `pytest-tmp-*`, `pytest-cache-files-*`, or `python-temp-check` directories are unreadable, first verify the resolved absolute target path is inside the current worktree, then reset ownership/ACL and remove only those temp directories. Never run broad recursive cleanup outside the verified worktree. +- A clean representative check is: + +```bash +python -m pytest tests/skill_scripts/test_report_harness_state.py::test_creates_run_directory_with_state_json tests/skill_scripts/test_excel_workbook_exporter.py tests/skill_scripts/test_validator_contracts.py -q --basetemp .pytest-fixed-check +``` + +## Production Database Safety +- The current workstation database connection points at the production database. +- Treat `C:/Users/ivychi/util/test/css04 CHD View_Customer.odc` and any local Workflow ERP database connection as production unless the user explicitly provides a verified test connection. +- Only `SELECT` SQL statements are allowed against the current database connection. +- Do not execute any DML or data-changing SQL, including `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `TRUNCATE`, stored procedures that modify data, or transaction-changing commands. +- If a task requires non-`SELECT` SQL, stop and ask the user for a verified non-production environment before proceeding. + +## SQL Execution Validation +- Any task that generates SQL is incomplete until the SQL executes and returned rows/columns/aggregates match the prompt intent. +- Under autonomous workflow, SQL execution validation is verifier-owned acceptance QA. The main agent may confirm that the verifier evidence packet records this validation, but must not run it directly. +- Use the containerized test DB by default; it runs SQL Server 2019 at compatibility level 80 for SQL Server 2000 syntax targeting. + +```bash +docker compose -f test_db/docker-compose.testdb.yml up -d +docker exec -i wferp-mssql-test /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Passw0rd\!234 -i /init/01_create_wferp_test.sql +export DB_DRIVER=mssql +export DB_AUTH_MODE=sql_auth +export DB_CONNECTION_STRING="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test" +export DB_ENV=test +python3 -m skill_scripts.cli_generate_select --prompt "查詢2026年的工程預算明細" --validate-execution --required-columns MK002,MK006 --min-rows 1 +``` + +- `skill_scripts/sql_router.py` blocks execution validation outside `DB_ENV=test` unless `--allow-non-test-db-execution` is explicitly passed. + +## WFERP Report Harness Gate Rules +- The `wferp-report` skill is a 13-phase harness. Do not simplify it into fewer + phases, collapse gates, remove validators, or replace the visual companion + confirmation/repair loop unless the user explicitly approves that structural + change before implementation. +- If `wferp-report` and this file differ, follow the stricter checkpoint, + validator, visual companion, encoding, and database-safety gate. Autonomous + workflow delegation does not waive these WFERP report gates. +- When using the `wferp-report` skill, checkpoint artifacts alone do not advance the workflow. +- After writing any checkpoint, the agent must present the visual companion or equivalent checkpoint view, obtain explicit user confirmation, and persist that confirmation before moving to the next phase. +- The visual companion must be the dynamic checkpoint companion for the current + run/current checkpoint. Do not use a static HTML mock, an old run URL, a + screenshot, or a text summary as a substitute for confirmation. +- Current checkpoint confirmation must match the current `run_id`, + `checkpoint_id`, `payload_hash`, `confirmation_id`, `created_at`, and + `confirmed_at`. A stale confirmation or old run URL is not valid. +- User-facing checkpoint content must be in Traditional Chinese. Technical JSON + may be available behind details, but the visible decision surface must render + real tables/charts/blocks from the current payload. +- Required validator evidence must be generated by reviewer/subagent roles and written under `review/validators/`. +- Validator evidence must come from a fresh reviewer/subagent invocation and + record reviewer identity, checked scope, input artifact paths, and timestamp. + The main agent must not self-certify validator pass. +- Local ad hoc checks may be recorded as preflight evidence only; they do not replace required validators such as `sql_safety_reviewer` or `schema_mapping_reviewer`. +- Validator, gating, visual companion, report design, and UAT logic must stay + domain-agnostic. Do not hardcode a business domain, customer, database, table, + view, or field name as a generic pass/fail condition; derive domain context + from the current prompt, workbook, normalized report plan, schema evidence, + SQL evidence, and data preview. +- Data preview checkpoints must show at least several returned rows in a table with column names, row count, and source summary. A JSON file path or summary-only preview is not enough. +- Workbooks or reports with many columns must show the complete field set in the + checkpoint, using horizontal scrolling/search/filtering as needed. Formula, + lookup, manual-only, format-only, and unresolved fields must show lineage and + handling decisions. +- Final visual review must include desktop and mobile viewport evidence through visual comparison, browser screenshots, or equivalent validator evidence. Mobile overflow, unreadable text, or overlapping UI is blocking. +- Large workbook, schema, or report payloads must be passed through files or current-session Codex context, not as full command-line arguments to Codex CLI. +- Do not use PowerShell here-strings, pipelines, or command-line arguments as + the transport for Chinese prompt/workbook/report payloads. Use UTF-8 files or + structured in-process data, and keep shell arguments ASCII-only. +- Validator/final-review/manifest JSON must be strict UTF-8 without BOM, parse as a JSON object, and contain no mojibake markers or repeated question-mark text. +- If a checkpoint, SQL, data preview, report payload, HTML, or validator file + contains mojibake or replacement-question-mark Chinese, stop and fix encoding + before moving forward. +- Mojibake scanning must cover checkpoint payloads, report payloads, HTML, + validator JSON, manifests, SQL readable labels, file-name displays, + chart/table labels, and final review artifacts. +- Use `python -m skill_scripts.cli_report_harness check-run-text-artifacts + --run-dir ` as the run-wide scan before Visual Companion + confirmation and before any validator marks the encoding/readability scope as + passed. +- After changing visual companion behavior, run a smoke check for current URL, + POST confirm, POST request changes, comments, selectedOptions, and persisted + confirmation before using it for a report run. +- Any final HTML or embedded payload change after final validators invalidates those validators. Rerun affected validators, recompute the manifest hash, and rerun delivery checks. +- Before reporting final delivery as complete, run: + +```bash +python -m skill_scripts.cli_report_harness check-delivery-artifacts --run-dir --required-validator report_content_reviewer --required-validator visual_taste_reviewer --required-validator react_technical_reviewer --required-validator delivery_reviewer +``` + +- If companion serving, user confirmation, or validator execution is blocked, stop and report the blocker instead of continuing. + +## WFERP Report 4-Step Visual Companion +- Preserve the internal 13-phase harness. Only simplify the user-facing Visual Companion into 4 steps: Source-to-Output Logic, SQL Query, Data Result and Report Design, Final Delivery. +- `state.json` controls all progression. Do not advance from chat memory, stale browser tabs, stale confirmation files, or artifact presence alone. +- Visual Companion confirmations must match the current `run_id`, `checkpoint_id`, and `payload_hash`. +- Step 1 must render source-to-output logic from prompt and/or Excel to final HTML/Excel outputs, including formulas, lookup tables, SQLite enrichment, and unresolved assumptions. +- Step 3 must render real current-run table previews, with default preview capped at 50 rows. JSON-only preview or column-only preview is not enough. +- Use Build Web Apps for Visual Companion/final HTML UI and Build Web Data Visualization for KPI/chart/table/report visualization when available. +- Use the spreadsheets skill and `@oai/artifact-tool` for true `.xlsx` generation and workbook verification. Do not use `openpyxl` as the default path. +- Required validators must be fresh reviewer/subagent artifacts with reviewer identity, checked scope, input artifacts, timestamp, findings, and evidence. The main agent cannot self-approve validator gates. +- Validator/gating logic must remain generic and must not hardcode any customer, database, table, field, report type, or business domain. + +## Repo-Specific Gotchas +- Preserve UTF-8 when editing Chinese/Vietnamese text or generated JSON/HTML. +- Keep existing Windows-style generated paths such as `HTML\\...` and `SQL\\...`; several generators intentionally emit them. +- `_Source/MoudleName.json` and code spell “Moudle” incorrectly; keep that filename/API spelling unless doing a coordinated migration. +- `_Source/` contains scripts plus generated JSON; do not delete generated JSON unless intentionally regenerating all dependent artifacts. +- There is no verified GitHub Actions, pre-commit, lint, formatter, or typecheck pipeline checked in at repo level. + +## Agent Communication +- Write repository documents, issues, PRDs, specs, and code comments in English unless the target file already requires another language. +- Reply to the user in Traditional Chinese unless they explicitly request another language. +- When following the Matt Pocock engineering skills workflow, after each completed unit of work, briefly state the recommended next step so the user can decide whether to continue, stop, or adjust direction. + +## Instruction Sources +- Install/run details: `INSTALLATION.md` and `OPERATIONS.md`. +- Current system/architecture context: `docs/superpowers/specs/2026-05-05-wferp-system-specification.md` and `docs/superpowers/specs/2026-05-05-wferp-architecture-design.md`. +- Issues and PRDs use GitHub repo `paulpai0412/wferp`; see `docs/agents/issue-tracker.md`. +- Triage label vocabulary and domain-doc expectations are in `docs/agents/triage-labels.md` and `docs/agents/domain.md`. +- Autonomous gated development workflow: `docs/agents/autonomous-development-workflow.yaml`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9128e51 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,49 @@ +# Workflow ERP Analytics Context + +## Glossary + +### Published Dashboard + +A versioned dashboard that has been approved for use and exposed through a standalone URL. A Published Dashboard is distinct from the existing published static schema documentation artifacts (`index.html`, `df_style.css`, and `HTML/`). + +### Dashboard Data Source + +The database or stored result set that a Published Dashboard reads from when rendering charts. + +For v1, Published Dashboards use a read-only replica as their Dashboard Data Source. They do not query the live production ERP database directly by default. + +### Published Dashboard Link + +The standalone URL used to view a Published Dashboard. + +For v1, a Published Dashboard Link is token-accessible: anyone with a valid unguessable token can view the dashboard without signing in. Tokens must be revocable and should support expiration because the link can expose ERP-derived analytics data if shared outside the intended audience. + +### Dashboard Refresh Policy + +The rule that determines when a Published Dashboard re-queries its Dashboard Data Source. + +For v1, Published Dashboards query the read-only replica when viewed and may be configured with an automatic refresh frequency. The refresh frequency must be bounded by product-level limits to protect the replica from excessive dashboard traffic. + +### Analyst Approval + +Human confirmation by a data analyst that a query result or dashboard version is correct enough to proceed. + +For v1, Analyst Approval is required after SQL execution result review before chart creation, and again before publishing a dashboard version as a Published Dashboard. + +### Chart Spec + +The product-level configuration for rendering a chart from an approved query result. + +For v1, a Chart Spec is edited through form controls such as chart type, x/y/series field mapping, sorting, title, color, and number/date formatting. Users do not directly edit the underlying visualization-library JSON in v1. + +### Design Template + +A dashboard visual style derived from a `DESIGN.md`-style markdown design system document. + +For v1, Design Templates are copied or adapted from the MIT-licensed `VoltAgent/awesome-design-md` collection, renamed into generic descriptive theme names, and batch-presented by the LLM for user selection. The product should avoid exposing brand names as official dashboard theme names, and should apply one selected Design Template at a time to avoid bloated LLM context and inconsistent generated styling. + +### Dashboard Canvas + +The editable layout surface where approved charts are placed before dashboard publication. + +For v1, the Dashboard Canvas uses a draggable and resizable grid layout rather than a freeform whiteboard. Layout should be stored as grid coordinates and dimensions for each chart so a Published Dashboard can render consistently. diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..97c5c91 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,187 @@ +# Installation Guide + +## Purpose + +This guide covers local setup for the Workflow ERP schema documentation and SQL-generation repo. + +## 1) Prerequisites + +- Python 3 +- `pip` +- Docker and Docker Compose +- access to a Workflow ERP SQL Server instance if you need to regenerate `_Source/` artifacts from scratch + +## 2) Repository root + +Run the commands in this guide from `schema/` unless a step explicitly says `_Source/`. + +## 3) Python environment + +Create and activate a virtual environment if needed: + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +Install the verified dependencies used by this repo: + +```bash +python3 -m pip install pymssql pandas pytest +``` + +Notes: + +- there is no checked-in dependency manifest; +- `pytest` is used by `tests/skill_scripts/` even though it is not declared in a repo-level requirements file; +- `pyodbc` is only needed if you choose that driver path in `database_client.py`; +- `openpyxl` is optional for ad hoc workbook inspection. The report Excel intake parser reads real `.xlsx` files with the Python standard library, so the harness tests do not require `openpyxl`. + +## 4) Test database setup + +Start the Dockerized SQL Server container: + +```bash +docker compose -f test_db/docker-compose.testdb.yml up -d +``` + +Initialize schema and seed data after the container becomes healthy: + +```bash +docker exec -i wferp-mssql-test /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Passw0rd\!234 -i /init/01_create_wferp_test.sql +``` + +Export the default test environment variables: + +```bash +export DB_DRIVER=mssql +export DB_AUTH_MODE=sql_auth +export DB_CONNECTION_STRING="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test" +export DB_ENV=test +``` + +## 5) First-run verification + +Verify the schema loader smoke test: + +```bash +pytest tests/skill_scripts/test_schema_loader.py -v +``` + +Build SQL-tooling artifacts: + +```bash +python3 -m skill_scripts.cli_generate_select --build-artifacts +``` + +Run a sample SQL prompt: + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" +``` + +Create a report harness run from a prompt: + +```bash +python3 -m skill_scripts.cli_report_harness create-run \ + --run-root wferp-report-runs \ + --run-id demo \ + --prompt "請產出費用分析" +``` + +Create an Excel confirmation checkpoint from a real workbook: + +```bash +python3 -m skill_scripts.cli_report_harness classify-workbook \ + --run-dir wferp-report-runs/demo \ + --input-file /path/to/需求.xlsx +python3 -m skill_scripts.cli_report_harness serve-checkpoint \ + --run-dir wferp-report-runs/demo \ + --host 127.0.0.1 \ + --port 0 +python3 -m skill_scripts.cli_report_harness wait-confirmation \ + --run-dir wferp-report-runs/demo \ + --checkpoint field_formula_classification +``` + +## 6) Expense-analysis E2E fixtures + +The expense-analysis E2E runs in two stages: + +1. local SQLite first-pass validation with a real SQLite database, real seed + data, generated SQL, and aggregate assertions; +2. Docker PostgreSQL validation as the local simulation of the formal MSSQL DB + execution target for the generated SQL Server SELECT subset. + +Run from the repository root: + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +``` + +The PostgreSQL runner first executes: + +```bash +bash scripts/run_expense_analysis_sqlite_e2e.sh +``` + +Then it starts `wferp-postgres-e2e`, seeds `wferp_e2e`, prints fixture counts +and the generated SQL, then runs: + +```bash +pytest tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v +``` + +## 7) Optional legacy regeneration setup + +If you need to rebuild the legacy schema artifacts, export the schema database connection environment variables before running the `_Source/` pipeline from the `_Source/` directory. + +Required: + +```bash +export WFERP_SCHEMA_DB_HOST="your-sql-server-host" +export WFERP_SCHEMA_DB_USERNAME="your-username" +export WFERP_SCHEMA_DB_PASSWORD="your-password" +``` + +Optional defaults: + +```bash +export WFERP_SCHEMA_DB_PORT="1433" +export WFERP_SCHEMA_DB_DATABASE="DSCSYS" +``` + +Run the rebuild from `_Source/` because the scripts use relative paths: + +```bash +cd _Source +python3 1_mssql_to_json.py +python3 2_FieldNameConvert2utf8.py +``` + +## 8) Troubleshooting + +### `pytest` not found + +Install it in the active environment: + +```bash +python3 -m pip install pytest +``` + +### `DB_DRIVER_NOT_INSTALLED` + +Install the required Python driver for the selected DB mode, typically `pymssql` for the default configuration. + +### Connection failures in execution validation + +- confirm the `wferp-mssql-test` container is healthy; +- confirm `DB_CONNECTION_STRING` and `DB_ENV=test` are exported in the current shell; +- rerun the seed command if the schema is missing. + +### PostgreSQL expense-analysis E2E fails to connect + +- confirm Docker is running; +- confirm port `55432` is free; +- rerun `bash scripts/run_expense_analysis_postgres_e2e.sh` so the fixture is + recreated before pytest executes. diff --git a/OPERATIONS.md b/OPERATIONS.md new file mode 100644 index 0000000..0c9fe7d --- /dev/null +++ b/OPERATIONS.md @@ -0,0 +1,221 @@ +# Operation Manual + +## Purpose + +This runbook covers the day-to-day workflows for regenerating documentation, building SQL artifacts, validating SQL, and running tests. + +## 1) Legacy schema regeneration + +Export the schema database connection environment variables before rebuilding metadata. + +Required: + +```bash +export WFERP_SCHEMA_DB_HOST="your-sql-server-host" +export WFERP_SCHEMA_DB_USERNAME="your-username" +export WFERP_SCHEMA_DB_PASSWORD="your-password" +``` + +Optional defaults: + +```bash +export WFERP_SCHEMA_DB_PORT="1433" +export WFERP_SCHEMA_DB_DATABASE="DSCSYS" +``` + +Run these commands from `schema/_Source/` because the scripts use relative paths: + +```bash +python3 1_mssql_to_json.py +python3 2_FieldNameConvert2utf8.py +``` + +### Verification + +1. confirm `_Source/MoudleName.json`, `_Source/TableName.json`, and + `_Source/TableStructure.json` were regenerated; +2. run `python3 -m skill_scripts.cli_generate_select --build-artifacts` from + repo root; +3. run `pytest tests/skill_scripts/test_schema_loader.py -v`. + +## 2) Build SQL-tooling artifacts + +Run from `schema/`: + +```bash +python3 -m skill_scripts.cli_generate_select --build-artifacts +``` + +Use this after changing schema-loading, relationship, or dictionary logic, or when `_Source/` artifacts change. + +## 3) Generate SQL from a prompt + +### Default command + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" +``` + +### Rule-only mode + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" --mode rule +``` + +### Shadow mode + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢2026年的工程預算明細" --mode shadow +``` + +### LLM-first mode + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢2026年的工程預算明細" --mode llm-first +``` + +## 4) Validate SQL execution and result correctness + +Start and seed the test DB if needed: + +```bash +docker compose -f test_db/docker-compose.testdb.yml up -d +docker exec -i wferp-mssql-test /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Passw0rd\!234 -i /init/01_create_wferp_test.sql +``` + +Export the test environment variables in the current shell: + +```bash +export DB_DRIVER=mssql +export DB_AUTH_MODE=sql_auth +export DB_CONNECTION_STRING="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test" +export DB_ENV=test +``` + +Run a prompt with execution validation: + +```bash +python3 -m skill_scripts.cli_generate_select --prompt "查詢2026年的工程預算明細" --validate-execution --required-columns MK002,MK006 --min-rows 1 +``` + +### Required operator rule + +Do not stop at SQL string inspection. A SQL-generation task is only complete after: + +1. the SQL executes successfully; and +2. the returned result matches the prompt intent. + +## 5) Run tests + +Run the full SQL-tooling suite: + +```bash +pytest tests/skill_scripts/ -v +``` + +Run one focused test file: + +```bash +pytest tests/skill_scripts/test_schema_loader.py -v +``` + +Use focused tests while iterating and the full suite before finalizing tooling changes. + +## 6) Report harness workflow + +Create a report run from a prompt: + +```bash +python3 -m skill_scripts.cli_report_harness create-run \ + --run-root wferp-report-runs \ + --run-id demo \ + --prompt "請產出費用分析" +``` + +Parse a real Excel requirement workbook and write the Excel confirmation checkpoint: + +```bash +python3 -m skill_scripts.cli_report_harness classify-workbook \ + --run-dir wferp-report-runs/demo \ + --input-file /path/to/需求.xlsx +python3 -m skill_scripts.cli_report_harness serve-checkpoint \ + --run-dir wferp-report-runs/demo \ + --host 127.0.0.1 \ + --port 0 +python3 -m skill_scripts.cli_report_harness wait-confirmation \ + --run-dir wferp-report-runs/demo \ + --checkpoint field_formula_classification +``` + +Create the SQL review checkpoint without executing the database: + +```bash +python3 -m skill_scripts.cli_generate_select \ + --prompt "查詢採購單前 20 筆" \ + --mode llm-first +python3 -m skill_scripts.cli_report_harness write-sql-review \ + --run-dir wferp-report-runs/demo \ + --sql "SELECT ..." +``` + +Execution validation remains gated. Do not execute DB queries until `sql_review` +has user confirmation and required validator evidence. Non-test DB environments +still require explicit allow evidence such as `--allow-non-test-db-execution` +where supported. + +## 7) Run the expense-analysis E2E + +Run the deterministic expense-analysis path. The runner validates SQLite first, +then uses Docker PostgreSQL as the local simulation of the formal MSSQL DB: + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +``` + +The runner: + +1. runs `bash scripts/run_expense_analysis_sqlite_e2e.sh`; +2. creates a real local SQLite DB in pytest and validates row counts, columns, + totals, exclusions, and percentages; +3. starts `wferp-postgres-e2e`; +4. recreates and seeds `dbo."ACPTA"` and `dbo."ACPTB"`; +5. prints fixture counts, included totals, excluded control totals, and generated SQL; +6. runs `pytest tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v`. + +The SQLite pytest file is skipped unless `WFERP_RUN_SQLITE_E2E=1` is set. The +PostgreSQL pytest file is skipped unless `WFERP_RUN_POSTGRES_E2E=1` is set. The +runner sets both flags. + +## 8) Operational decision guide + +| Task | Work area | +| --- | --- | +| Source ERP metadata changed | `_Source/` | +| SQL generation logic changed | `skill_scripts/` + `tests/skill_scripts/` | +| Query execution validation changed | `skill_scripts/` + `test_db/` | +| Agent usage guidance changed | `skills/workflow-erp-sql-generator/` | + +## 9) Common failure cases + +### Generated SQL looks valid but fails task intent + +Run execution validation with required columns or aggregate checks. Do not accept the query based only on syntax. + +### Test DB rejects execution + +- check container health; +- confirm the DB environment variables are exported; +- confirm the seed SQL ran successfully. + +### HTML output breaks after regeneration + +- verify you ran the scripts from `_Source/`; +- verify generated links still keep the expected Windows-style `HTML\\...` format; +- rerun the pipeline in sequence instead of skipping intermediate steps. + +## 10) Related references + +- `AGENTS.md` +- `INSTALLATION.md` +- `skills/workflow-erp-sql-generator/SKILL.md` +- `skills/workflow-erp-sql-generator/references/functions.md` diff --git a/_Source/1_mssql_to_json.py b/_Source/1_mssql_to_json.py index 6605e00..a986695 100644 --- a/_Source/1_mssql_to_json.py +++ b/_Source/1_mssql_to_json.py @@ -3,12 +3,7 @@ import re import json import pymssql - -# mssql parameters -SERVER_IP = 'your_server_ip' -DATABASE = 'DSCSYS' -USERNAME = 'your_username' -PASSWORD = 'password' +from schema_db_config import SchemaDbConfig Get_MoudleNameSQL = '''SELECT ADMMA.MA001 as ModuleID, ADMMA.MA002 as ModuleName ,case ADMMA.MA001 @@ -98,7 +93,15 @@ def default(self,obj): def Table(SQLString, Columns): try: # -- 建立 MSSQL 連線 - conn = pymssql.connect(server=SERVER_IP, user=USERNAME, password=PASSWORD, database=DATABASE) + config = SchemaDbConfig.from_env() + conn = pymssql.connect( + server=config.host, + port=config.port, + user=config.username, + password=config.password, + database=config.database, + charset="utf8", + ) cursor = conn.cursor() cursor.execute(SQLString) @@ -171,4 +174,4 @@ def Table(SQLString, Columns): except Exception as e: print(e) fs.write(_jsonData) - fs.close() \ No newline at end of file + fs.close() diff --git a/_Source/schema_db_config.py b/_Source/schema_db_config.py new file mode 100644 index 0000000..de47d80 --- /dev/null +++ b/_Source/schema_db_config.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +from dataclasses import dataclass +import os + + +REQUIRED_ENV_VARS = ( + "WFERP_SCHEMA_DB_HOST", + "WFERP_SCHEMA_DB_USERNAME", + "WFERP_SCHEMA_DB_PASSWORD", +) + + +class SchemaDbConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class SchemaDbConfig: + host: str + port: int + database: str + username: str + password: str + + @classmethod + def from_env(cls, environ=None): + env = os.environ if environ is None else environ + missing = [name for name in REQUIRED_ENV_VARS if not env.get(name)] + if missing: + raise SchemaDbConfigError("Missing schema DB env vars: " + ", ".join(missing)) + + try: + port = int(env.get("WFERP_SCHEMA_DB_PORT", "1433")) + except ValueError as exc: + raise SchemaDbConfigError("WFERP_SCHEMA_DB_PORT must be an integer") from exc + + return cls( + host=env["WFERP_SCHEMA_DB_HOST"], + port=port, + database=env.get("WFERP_SCHEMA_DB_DATABASE", "DSCSYS"), + username=env["WFERP_SCHEMA_DB_USERNAME"], + password=env["WFERP_SCHEMA_DB_PASSWORD"], + ) + + def redacted(self): + return { + "host": self.host, + "port": self.port, + "database": self.database, + "username": self.username, + "password": "***", + } + + +def from_env(environ=None): + return SchemaDbConfig.from_env(environ) diff --git a/docs/agents/autonomous-development-workflow.yaml b/docs/agents/autonomous-development-workflow.yaml new file mode 100644 index 0000000..2109229 --- /dev/null +++ b/docs/agents/autonomous-development-workflow.yaml @@ -0,0 +1,1073 @@ +schema_version: "1.0" +name: autonomous-development-workflow +kind: opencode_policy_runbook +status: draft +owner: repo-agents + +purpose: > + Define a gated, self-cycling OpenCode development workflow for this repo. + This is a project-owned policy/runbook YAML. OpenCode does not execute this + file directly; agents use it as the operating contract and future runners may + translate it into OpenCode skills, commands, permissions, and shell gates. + +priority_bridge: + applies_when: + - user_invokes_autonomous_development_workflow + - main_agent_is_orchestrating_ready_for_agent_issue + rule: > + Repo autonomous workflow role isolation is the controlling task contract. + Generic agent requirements to personally run diagnostics, tests, builds, + manual QA, browser QA, SQL execution validation, or review gates must be + satisfied by fresh worker/verifier artifacts rather than direct main-agent + execution. + conflict_policy: > + If platform or generic agent instructions appear to require direct + main-agent QA, the main agent must stop and report a policy conflict instead + of running issue or phase QA itself. + main_agent_contract_validation_only: + allowed: + - validate_worker_result_schema + - validate_evidence_packet_schema + - validate_test_case_catalog_shape + - validate_failure_registry_shape + - validate_refactor_candidate_audit_shape + - confirm_required_refs_exist + - compare_acceptance_criteria_ids_to_evidence_refs + - confirm_proof_of_separation_fields_are_present + - update_handoff_or_tracker_metadata_from_verified_refs + forbidden: + - run_pytest_or_test_commands + - run_py_compile_or_build_commands_for_issue_code + - run_lsp_diagnostics_on_issue_code + - run_manual_driver_or_surface_qa + - run_browser_or_playwright_qa + - run_sql_execution_validation + - run_review_work_as_main_agent_qa + +scope: + repository: paulpai0412/wferp + issue_tracker: github + tracker_commands_reference: docs/agents/issue-tracker.md + domain_context: + - AGENTS.md + - CONTEXT.md + - docs/agents/domain.md + - docs/agents/triage-labels.md + default_language: + user_replies: traditional_chinese + repo_docs_issues_prds_comments: english_unless_target_requires_other_language + +automation_boundaries: + allowed_without_human_approval: + - read_repo_context + - create_or_update_local_docs + - run_non_destructive_setup_checks + - install_python_dependencies_from_repo_docs + - start_and_seed_local_test_db + - implement_ready_for_agent_issue + - delegate_tests_diagnostics_e2e_and_review_gates_to_worker_or_verifier + - commit_after_all_required_gates_pass + - push_branch_to_remote + - create_pull_request + requires_human_approval: + - install_or_update_external_skills_not_already_approved + - merge_pull_request_after_review + - close_issue_after_merged_pull_request + - run_non_test_database_execution + - destructive_git_or_shell_operations + - install_hooks_plugins_or_scripts_from_third_party_sources + forbidden: + - force_push_to_main_or_master + - bypass_review_or_test_gates + - weaken_or_delete_failing_tests_to_pass + - commit_secrets_or_credentials + - execute_write_sql_or_arbitrary_multi_statement_sql + - main_agent_direct_issue_or_phase_qa + +skills: + built_in_or_current: + requirement_clarification: + - grill-me + - grill-with-docs + - zoom-out + planning_and_issue_flow: + - to-prd + - to-issues + - triage + implementation: + - tdd + - karpathy-guidelines + - diagnose + frontend_and_browser: + - playwright + - web-design-engineer + - frontend-ui-ux + review_and_quality: + - review-work + - ai-slop-remover + - improve-codebase-architecture + git_and_release: + - git-master + docs_and_handoff: + - grill-with-docs + - write-a-skill + - handoff + external_installed_minimal_ecc: + source_repo: affaan-m/everything-claude-code + license: MIT + install_policy: copied_pure_skill_markdown_only + installed_paths: + - /home/timmypai/.config/opencode/skills/e2e-testing/SKILL.md + - /home/timmypai/.config/opencode/skills/browser-qa/SKILL.md + - /home/timmypai/.config/opencode/skills/canary-watch/SKILL.md + active_session_note: > + Current OpenCode sessions may not hot-load newly copied skills. Restart + OpenCode before relying on skill(name="e2e-testing"), skill(name="browser-qa"), + or skill(name="canary-watch"). Until then, treat the files as referenced + policy content rather than callable skills. + excluded_by_policy: + - npm_plugin_ecc_universal + - hooks_runtime + - scripts + - full_opencode_config_overlay + - broad_rules_install + +global_gates: + spec_gate: + required: true + pass_when: + - problem_statement_is_clear + - public_interface_or_surface_is_named + - acceptance_criteria_are_observable + - out_of_scope_is_explicit + - unresolved_questions_are_moved_to_needs_info + traceability_gate: + required: true + pass_when: + - every_acceptance_criterion_maps_to_evidence + - evidence_includes_tests_or_manual_surface_qa + - known_limitations_are_documented + tdd_gate: + required_for_code_changes: true + pass_when: + - first_behavior_test_fails_before_implementation + - implementation_is_minimal_for_current_test + - tests_use_public_interface_not_private_helpers + - focused_tests_pass + diagnostics_and_build_gate: + required: true + owner: worker_self_check_or_pr_verifier + pass_when: + - lsp_diagnostics_clean_for_changed_files_when_supported + - related_tests_pass + - build_or_equivalent_command_passes_when_applicable + - pre_existing_failures_are_named_and_not_mixed_with_issue_work + surface_qa_gate: + required: true + owner: pr_verifier_or_phase_verifier + pass_when: + - deliverable_is_used_through_real_user_surface + - happy_path_observed + - at_least_one_error_or_refusal_path_observed_when_applicable + - evidence_is_recorded_in_issue_or_pr_notes + review_gate: + policy: major_full_minor_lightweight + owner: pr_verifier + major_change_requires: + - review-work_all_agents_pass + lightweight_change_requires: + - goal_check_passes + - changed_file_diagnostics_or_doc_review_passes + - relevant_tests_or_not_applicable_reason_recorded + risk_gate: + applies_when: + - refactor_candidate_issue + pass_when: + - candidate_is_low_risk_with_high_or_medium_value + - or_medium_risk_has_regression_bucket_and_rollback_plan + - high_risk_candidate_is_ready_for_human_or_split_smaller + security_gate: + required: true + pass_when: + - no_secrets_in_diff + - no_new_unapproved_network_or_script_execution_surface + - data_access_and_sql_remain_read_only_unless_explicitly_approved + - third_party_skill_or_tool_installs_pass_external_skill_gate + git_gate: + required: true + pass_when: + - git_status_and_diff_reviewed_before_commit + - commit_contains_only_relevant_files + - commit_message_matches_repo_style + - push_and_pr_actions_follow_branch_and_pr_policy + +repo_specific_gates: + setup_gate: + commands: + - python3 -m pip install pymssql pandas pytest + - pytest tests/skill_scripts/test_schema_loader.py -v + - python3 -m skill_scripts.cli_generate_select --build-artifacts + pass_when: + - required_python_dependencies_available + - schema_loader_smoke_test_passes + - artifacts_build_if_needed + sql_generation_gate: + applies_when: + - skill_scripts_changed + - prompt_to_sql_behavior_changed + - dashboard_data_source_or_query_execution_changed + required_commands: + - docker compose -f test_db/docker-compose.testdb.yml up -d + - docker exec -i wferp-mssql-test /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Passw0rd\!234 -i /init/01_create_wferp_test.sql + - export DB_DRIVER=mssql + - export DB_AUTH_MODE=sql_auth + - export DB_CONNECTION_STRING="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test" + - export DB_ENV=test + pass_when: + - sql_is_single_select_statement + - sql_server_2000_guard_passes + - metadata_and_prompt_consistency_pass + - sql_executes_in_test_db + - returned_columns_rows_and_aggregates_match_prompt_intent + static_docs_gate: + applies_when: + - index_html_or_df_style_or_HTML_changed + - _Source_generator_changed + pass_when: + - legacy_generators_run_from__Source_when_regeneration_needed + - utf8_chinese_vietnamese_text_preserved + - windows_style_generated_paths_preserved + - browser_opens_index_html_and_iframe_pages_load + browser_e2e_gate: + applies_when: + - web_ui_or_static_html_surface_changed + - dashboard_builder_ui_changed + skills_or_references: + - playwright + - e2e-testing + - browser-qa + pass_when: + - browser_loaded_real_local_or_preview_url + - critical_user_journey_executed + - console_and_network_failures_checked + - desktop_and_mobile_viewports_checked_when_visual_behavior_changed + - screenshots_or_trace_recorded_for_failures + +external_skill_gate: + purpose: Decide whether to install missing third-party skills. + pass_when: + - source_repo_is_allowlisted + - license_is_compatible + - requested_component_is_necessary_for_current_workflow_gap + - install_plan_excludes_hooks_scripts_plugins_unless_explicitly_approved + - target_paths_do_not_overwrite_existing_skills_without_diff_review + - installation_is_verified_by_reading_installed_files + allowlist: + - repo: affaan-m/everything-claude-code + allowed_components: + - skills/e2e-testing/SKILL.md + - skills/browser-qa/SKILL.md + - skills/canary-watch/SKILL.md + default_policy: pure_markdown_copy_only + deny_by_default: + - npm_packages + - hooks + - shell_scripts + - mcp_configs_with_secrets + - full_config_overlays + +issue_selection: + source: github_issues + query_policy: + parent_scope: same_prd_or_parent_issue_only + required_label: ready-for-agent + allowed_slice_type: AFK + blockers: all_blockers_closed_or_satisfied + ordering: + - dependency_topological_order + - oldest_ready_issue_first + execution: + mode: serial_single_issue + allow_parallel_ready_issues: false + rationale: + - one_issue_one_branch_one_commit_one_pr + - fresh_worker_and_verifier_session_per_issue + - release_worker_cleanup_tracks_one_selected_issue_at_a_time + on_no_ready_issue: + - run_prd_phase_completeness_audit + skip_when: + - issue_has_needs_info_or_needs_triage + - acceptance_criteria_not_observable + - issue_requires_human_judgment_or_external_secret + - working_tree_conflicts_with_unrelated_user_changes + +branch_and_pr_policy: + granularity: one_issue_one_branch_one_commit_one_pr + branch_name_template: "agent/issue-{issue_number}-{slug}" + commit_policy: + auto_commit_after_all_gates_pass: true + require_clean_relevant_diff: true + avoid_committing_unrelated_dirty_worktree_changes: true + push_policy: + automatic_after_all_gates_pass: true + pr_policy: + automatic_after_all_gates_pass: true + body_must_include: + - linked_issue + - compact_acceptance_criteria_matrix + - verification_summary_and_artifact_refs + - verifier_manual_qa_result_summary + - review_gate_result + - known_limitations_or_pre_existing_failures + final_qa_statement_requires_verifier_packet: true + merge_policy: + requires_human_review_and_merge_approval: true + runtime_override_policy: + default_mode: human_required + runtime_override_allowed: true + allowed_override_modes: + - bypass_approval + set_only_at_workflow_start: true + mutable_after_start: false + scope: workflow_run_only + applies_to: all_prs_created_by_this_run + affects_stage: release_worker_only + bypasses: + - human_merge_approval_policy_satisfied + does_not_bypass: + - verifier_packet_status_pass + - required_checks_pass + - pr_is_mergeable + - review_gate + - surface_qa_gate + - diagnostics_and_build_gate + +context_budget_policy: + measurement: percent_of_available_session_context_used + checkpoint_warning_at_percent: 45 + stop_and_rotate_at_percent: 50 + checkpoint_template_path: docs/agents/runtime/context-checkpoint.yaml + checkpoint_only_cross_session_resume: true + phase_1_checkpoint_to_new_session_policy: + status: active_mvp + implementation_spec_path: docs/agents/runtime/phase-1-orchestrator-checkpoint-to-new-session.md + applies_to: + - main_orchestrator_session_only + trigger: + - selected_issue_is_ready_for_agent + - selected_issue_slice_type_is_AFK + - orchestrator_is_about_to_enter_per_issue_flow + sequence: + - create_or_update_checkpoint_before_pr_flow + - derive_compact_payload_from_checkpoint + - write_new_session_continuation_request + - continue_with_selected_issue_pr_flow_in_fresh_session + exclusions: + - do_not_apply_to_issue_worker_sessions + - do_not_apply_to_pr_verifier_sessions + - do_not_apply_to_phase_verifier_sessions + notes: + - Phase 1 now uses checkpoint plus plugin-driven fresh-session continuation for new issue PR flow startup. + - The 50 percent stop-and-rotate policy remains the long-term cross-session contract and is not automated by this MVP. + resume_policy: + allowed_context: + - issue_packet + - repo_policy_docs + - latest_context_checkpoint + - compact_handoff_refs + forbidden_context: + - full_prior_worker_transcript + - raw_test_logs + - raw_browser_traces + - raw_sql_execution_logs + requirements: + - create_or_update_checkpoint_before_rotation + - resume_from_checkpoint_not_chat_history + - keep_checkpoint_at_or_below_80_lines + +artifact_caps_policy: + raw_evidence_policy: index_only_in_repo_docs_and_main_context + line_caps: + issue_packet: 80 + issue_handoff: 35 + evidence_packet: 60 + context_checkpoint: 80 + worker_result: 80 + test_case_catalog: 80 + failure_registry: 80 + refactor_candidate_audit: 80 + templates_must_declare_caps: true + script_gate: scripts/agent_context_budget_check.py + forbidden_payloads: + - full_worker_transcript + - full_verifier_transcript + - raw_test_logs + - raw_browser_traces + - raw_sql_execution_logs + - screenshot_binary_or_base64 + +release_worker_policy: + role: release_worker + purpose: Narrow post-verifier PR release and issue-close executor. + merge_approval_policy: + default_mode: human_required + runtime_override_source: workflow_start_and_checkpoint + bypass_approval_requires: + - approval_override_mode_is_bypass_approval + - override_scope_is_workflow_run_only + - override_was_set_at_workflow_start + - override_was_not_mutated_after_start + may_run_only_after: + - verifier_packet_status_pass + - pr_is_mergeable + - required_checks_pass + - human_merge_approval_policy_satisfied + allowed_actions: + - confirm_pr_mergeability_and_required_checks + - merge_approved_pull_request + - run_post_merge_workspace_hygiene + - close_linked_issue_after_merge + - post_compact_release_summary_with_evidence_packet_ref + - post_merge_workspace_hygiene_summary + execution_order: + - merge_approved_pull_request + - run_post_merge_workspace_hygiene + - close_linked_issue_after_merge + - post_compact_release_summary_with_evidence_packet_ref + - post_merge_workspace_hygiene_summary + close_issue_only_after: + - post_merge_workspace_hygiene_passes + approval_gate_evaluation: + human_required_mode: + require: + - verifier_packet_status_pass + - pr_is_mergeable + - required_checks_pass + - human_merge_approval_policy_satisfied + bypass_approval_mode: + require: + - verifier_packet_status_pass + - pr_is_mergeable + - required_checks_pass + skip: + - human_merge_approval_policy_satisfied + post_merge_workspace_hygiene: + scope: primary_workspace_and_issue_worktree_hygiene_only + primary_workspace: + inspect_current_branch_first: true + if_not_main_and_clean: switch_to_main + if_not_main_and_dirty: create_wip_branch_and_stash_then_switch_to_main + if_main_and_dirty: create_wip_branch_and_stash + if_main_and_clean: no_branch_restore_needed + wip_branch_name_template: "agent/wip/post-merge-issue-{issue_number}-{yyyyMMdd-HHmm}" + stash_message_template: "post-merge hygiene preserve issue-{issue_number} from {source_branch}" + final_git_status_must_be_clean: true + issue_worktree: + remove_when_pr_merged_and_clean: true + block_when_dirty_or_unknown: true + summary: + checkpoint_fields: + - primary_workspace_branch_before + - primary_workspace_branch_after + - dirty_state_detected + - wip_branch_created + - stash_created + - workspace_clean_after + - issue_worktree_removed + - cleanup_status + - blocked_reason + github_comment_uses_same_fields: true + blocked_reason_enum: + - dirty_workspace_preserve_failed + - switch_main_failed + - fast_forward_main_failed + - worktree_remove_failed + - workspace_not_clean_after_cleanup + - issue_worktree_dirty_blocked + forbidden_actions: + - implement_issue_scope + - run_final_acceptance_qa + - bypass_required_checks_or_reviews + - merge_without_required_human_approval + - close_issue_before_pr_merge + - modify_issue_scope_files_during_cleanup + blocked_when: + - verifier_packet_missing_or_not_pass + - pr_not_mergeable + - required_checks_pending_or_failed + - human_approval_required_but_absent + - post_merge_workspace_hygiene_failed + - issue_worktree_dirty_or_unknown + +session_isolation_policy: + main_agent_role: orchestrator_only + main_agent_may_implement: + - workflow_self_repair_only + - worker_boot_failure_recovery + - tiny_process_doc_fix + main_agent_must_not: + - implement_general_issue_scope + - retain_full_worker_transcript_across_issues + - run_issue_or_phase_qa_except_contract_validation + - run_tests_builds_diagnostics_or_manual_qa_for_issue_acceptance + - run_review_work_as_issue_or_pr_qa + contract_validation_definition: + allowed: + - schema_check_worker_result_required_fields + - schema_check_evidence_packet_required_fields + - verify_worker_and_verifier_refs_are_present + - verify_acceptance_criteria_map_to_evidence_refs + - verify_worker_verifier_proof_of_separation_is_present + - validate_test_case_catalog_shape + - validate_failure_registry_shape + - validate_refactor_candidate_audit_shape + - update_handoff_and_tracker_with_compact_refs + forbidden: + - run_pytest + - run_py_compile + - run_lsp_diagnostics_on_issue_code + - run_browser_qa + - run_sql_execution_validation + - run_manual_surface_driver + - run_review_work + issue_execution: + use_fresh_worker_session_per_issue: true + use_fresh_verifier_session_per_pr: true + use_fresh_verifier_session_per_phase: true + allow_parallel_ready_issues: false + ultrawork_policy: + allowed_inside_single_issue_worker_or_single_verifier: true + forbid_orchestrator_multi_issue_ultrawork: true + requires_explicit_concurrency_policy_before_parallel_issue_execution: true + worker_reads_issue_packet_first: true + worker_must_terminate_after_issue: true + worker_returns_structured_result_only: true + verifier_returns_evidence_packet_only: true + proof_of_separation_required: true + allow_specialist_helpers: + - explore + - librarian + - oracle + max_delegation_depth: 3 + issue_bootstrap_context: + - issue_packet + - AGENTS.md + - CONTEXT.md + - docs/agents/domain.md + - docs/agents/autonomous-development-workflow.yaml + - parent_prd_relevant_slice + - previous_issue_handoff + +handoff_policy: + on_success: required + on_blocked: required + on_failed_after_retries: required + repo_path_template: "docs/agents/handoffs/issue-{issue_number}.yaml" + github_summary_required: true + include_sections: + - issue_identity + - acceptance_criteria_evidence + - compact_verification_summary + - evidence_packet_refs + - files_changed_summary + - blockers_or_risks + - next_recommended_step + +issue_packet_policy: + template_path: docs/agents/issue-packet-template.yaml + required_fields: + - issue + - branch + - objective + - acceptance_criteria + - scope + - bootstrap_context + - implementation_notes + - verification_plan + - handoff_contract + - result_contract + optional_fields: + - test_case + forbidden_payloads: + - full_orchestrator_transcript + - unrelated_repo_search_dumps + - speculative_solution_branches + +worker_result_policy: + template_path: docs/agents/worker-result-template.yaml + required_fields: + - issue + - branch + - status + - summary + - files_changed + - verification + - pr + - blockers + - next_recommended_step + - evidence_packet_refs + - role_boundary + optional_fields: + - test_case_repair + forbidden_payloads: + - full_worker_transcript + - long_form_chain_of_thought + - repeated_search_output + +evidence_packet_policy: + template_path: docs/agents/evidence-packet-template.yaml + purpose: > + Keep verbose verification evidence outside the main agent context while + preserving an auditable chain from acceptance criteria to raw artifacts. + required_for: + - every_issue_pr_verifier_run + - every_phase_cross_issue_verifier_run + - every_e2e_test_case_verifier_run + storage_policy: + repo_handoff_keeps: compact_summary_and_refs_only + raw_evidence_kept_in: ci_or_local_artifact_bundle + main_agent_consumes: evidence_packet_index_only + raw_evidence_policy: index_only_no_raw_logs_or_traces_in_repo_docs + required_fields: + - subject + - verifier + - proof_of_separation + - status + - acceptance_criteria_matrix + - gates + - role_boundary + - artifact_manifest + - compact_summary + - next_recommended_step + optional_fields: + - test_case_verification + forbidden_payloads: + - full_command_output + - full_test_logs + - screenshot_binary_or_base64 + - verbose_browser_trace + - full_verifier_transcript + line_cap: 60 + +e2e_test_case_issue_loop: + spec_path: docs/agents/e2e/test-case-issue-loop.md + catalog_path: docs/agents/e2e/test-case-catalog.yaml + failure_registry_path: docs/agents/e2e/failure-registry.yaml + issue_unit: one_github_issue_per_executable_e2e_test_case + timing: + draft_intent_created_during: create_prd + executable_issue_materialized_after: feature_issue_verifier_passes + labels: + canonical_ready_label: ready-for-agent + supplemental: [e2e-test-case, blocked-by-spec, blocked-by-infra] + ready_for_agent_requires: + - source_ref_present + - setup_execution_and_expected_outcome_explicit + - surface_and_regression_bucket_declared + - evidence_requirements_index_only + same_issue_fix_allowed: [product, test, fixture, harness, test_data_seed] + worker_forbidden_changes: + - weaken_assertions + - change_expected_outcome_without_spec_repair_issue + - claim_final_e2e_acceptance + loop_control: + max_worker_verifier_cycles: 3 + max_same_root_cause_repairs: 2 + shared_failure_parent_issue_threshold: 2 + pass_condition: + target_case: pass + required_regression_bucket: pass + proof_of_separation: true + artifact_manifest_present: true + release_worker_may_rerun_e2e: false + +refactor_candidate_loop: + spec_path: docs/agents/refactor/refactor-candidate-loop.md + audit_template_path: docs/agents/refactor/refactor-candidate-audit-template.yaml + issue_unit: one_github_issue_per_refactor_candidate + timing: + starts_after: e2e_test_case_issue_loop + continues_to: documentation_and_tracker_update + execution_policy: + conditional: true + no_candidates: skip_with_refactor_candidate_audit_note + candidate_source: phase_refactor_audit + labels: + canonical_ready_label: ready-for-agent + supplemental: [refactor-candidate, blocked-by-spec, blocked-by-risk] + safety_refactor_only: + allowed: [rename, extract_function_or_class, remove_duplication, simplify_control_flow, improve_boundaries, reduce_coupling, improve_testability] + forbidden: [new_feature, ux_change, sql_semantics_change, public_contract_change, expected_output_change, acceptance_criteria_change, e2e_expected_outcome_change] + risk_gate: + low_risk: high_or_medium_value_may_be_ready_for_agent + medium_risk: requires_regression_bucket_and_rollback_plan + high_risk: ready_for_human_or_split_smaller + regression_policy: + mode: impact_based + uncertain_impact_fallback: rerun_all_materialized_e2e_test_case_regression_buckets + metadata_update_policy: + may_update_e2e_metadata_refs: true + may_change_expected_outcomes: false + +phases: + - id: clarify_requirements + goal: Resolve product intent and ambiguous decisions before implementation. + primary_skills: + - grill-me + - grill-with-docs + actions: + - read_domain_docs + - ask_one_decision_question_at_a_time + - record_decisions_in_context_or_prd_notes + exit_gate: spec_gate + + - id: create_prd + goal: Convert clarified context into a PRD. + primary_skills: + - to-prd + actions: + - synthesize_problem_solution_user_stories_decisions_tests_out_of_scope + - draft_e2e_test_intents_from_acceptance_criteria + - publish_prd_to_github_issue_with_needs_triage + - store_local_copy_when_useful + exit_gate: spec_gate + + - id: create_vertical_slice_issues + goal: Break the PRD into independently verifiable tracer-bullet issues. + primary_skills: + - to-issues + - triage + actions: + - draft_AFk_and_HITL_vertical_slices + - confirm_granularity_dependencies_and_AFk_status + - publish_issues_in_dependency_order + - label_ready_for_agent_when_fully_specified + exit_gate: traceability_gate + + - id: environment_setup + goal: Prepare local dependencies and test surfaces. + primary_skills: + - karpathy-guidelines + actions: + - install_documented_python_dependencies + - start_and_seed_test_db_when_sql_execution_needed + - run_smoke_tests + - install_external_skills_only_through_external_skill_gate + exit_gate: setup_gate + + - id: issue_development_loop + goal: Orchestrate ready AFK issues through fresh issue-scoped worker sessions until the PRD phase is complete or blocked. + loop: + select_next_issue: issue_selection + on_no_ready_issue: + - run_prd_phase_completeness_audit + - if_prd_phase_incomplete_return_to_create_vertical_slice_issues + - if_prd_phase_complete_continue_to_e2e_test_case_issue_loop + - if_audit_cannot_confirm_coverage_or_publish_more_issues_stop_for_human_decision + max_issue_gate_repair_attempts: 3 + on_worker_boot_failure: + - create_handoff_with_attempts_and_failures + - stop_for_human_decision + on_invalid_worker_result: + - request_worker_result_repair_once + - create_handoff_with_attempts_and_failures + - stop_for_human_decision + on_repair_attempts_exhausted: + - run_diagnose_or_consult_oracle_when_debugging_or_architecture_blocks + - create_handoff_with_attempts_and_failures + - stop_for_human_decision + per_issue_flow: + - create_or_update_checkpoint_for_selected_AFK_issue + - derive_compact_payload_from_checkpoint + - write_new_session_continuation_request_before_pr_flow + - create_or_switch_issue_branch + - build_issue_packet + - spawn_fresh_issue_worker + - wait_for_worker_result + - validate_worker_result_schema + - spawn_fresh_pr_verifier + - wait_for_evidence_packet + - validate_evidence_packet_schema + - record_issue_handoff + - stop_for_pr_merge_review + + - id: issue_worker_execution + goal: Execute one issue inside a fresh isolated worker session. + primary_skills: + - tdd + - karpathy-guidelines + - git-master + fallback_skills: + - diagnose + actions: + - read_issue_packet_only + - expand_context_on_demand + - map_acceptance_criteria_to_planned_evidence + - run_tdd_red_green_refactor_vertical_slices + - run_implementation_self_checks_for_feedback + - report_self_check_summary_without_final_acceptance_claim + - commit_if_all_gates_pass + - push_branch_if_all_gates_pass + - create_pr_if_all_gates_pass + - write_worker_result_or_draft_handoff_refs + - post_implementation_self_check_summary_without_final_acceptance_claim + - emit_structured_result_object + - terminate_worker_session + gates: + - tdd_gate + - implementation_self_check_gate + - git_gate + + - id: per_issue_tdd + goal: Implement each issue through public-interface behavior tests. + primary_skills: + - tdd + - karpathy-guidelines + fallback_skills: + - diagnose + gates: + - tdd_gate + - implementation_self_check_gate + + - id: per_issue_e2e + goal: Verify each issue through its smallest real user surface. + policy: verifier_owned_issue_small_e2e + primary_skills: + - playwright + - e2e-testing + - browser-qa + examples: + cli: run_command_and_check_happy_and_error_paths + library: run_minimal_driver_script + api: hit_live_process_with_curl_or_driver + browser: load_page_click_fill_assert_console_network_and_screenshot_failures + sql: execute_against_test_db_and_validate_result_evidence + gates: + - surface_qa_gate + + - id: per_pr_review + goal: Ensure a PR is releasable before human merge review. + policy: major_full_minor_lightweight + major_primary_skill: review-work + lightweight_checks: + - goal_vs_diff_check + - changed_file_diagnostics + - relevant_tests_or_not_applicable_reason + - manual_surface_qa_notes + gates: + - review_gate + - security_gate + + - id: pr_verifier_execution + goal: Independently verify one issue PR from its worker result, diff, and issue packet without importing verbose evidence into the main agent context. + policy: fresh_verifier_worker_per_pr + primary_skills: + - karpathy-guidelines + - review-work + actions: + - read_issue_packet_worker_result_and_diff_only + - rerun_required_focused_checks_or_confirm_ci_artifacts + - rerun_surface_specific_qa_when_artifact_absent_or_untrusted + - compare_acceptance_criteria_to_observed_evidence + - confirm_worker_verifier_role_separation + - emit_evidence_packet_with_artifact_manifest + - terminate_verifier_session + gates: + - traceability_gate + - diagnostics_and_build_gate + - surface_qa_gate + - review_gate + + - id: commit_push_and_create_pr + goal: Inside the issue worker, save verified local work and automatically publish the verified issue branch and PR. + primary_skills: + - git-master + actions: + - inspect_git_status_diff_and_log + - stage_only_relevant_files + - commit_with_accurate_message + - push_branch_to_remote + - create_pull_request + gates: + - git_gate + + - id: prd_phase_completeness_audit + goal: When no ready AFK issue remains, determine whether the current PRD phase is fully covered before E2E test case issue materialization or closure. + primary_skills: + - to-issues + - triage + - zoom-out + actions: + - compare_prd_acceptance_criteria_and_user_journeys_to_completed_open_and_missing_issue_slices + - confirm_no_dependency_ready_issue_was_missed + - if_prd_phase_incomplete_return_to_create_vertical_slice_issues + - if_prd_phase_complete_continue_to_e2e_test_case_issue_loop + - record_audit_summary_and_next_recommended_step + exit_paths: + prd_phase_incomplete: + - create_vertical_slice_issues + - issue_development_loop + prd_phase_complete: + - e2e_test_case_issue_loop + - phase_refactor + - documentation_and_tracker_update + blocked: + - stop_for_human_decision + + - id: e2e_test_case_issue_loop + goal: Materialize executable E2E test intents into GitHub issues and cycle each case through repair, fresh verification, and release readiness. + policy: test_case_issue_loop + reference: docs/agents/e2e/test-case-issue-loop.md + primary_skills: + - to-issues + - triage + - e2e-testing + actions: + - convert_ready_e2e_intents_to_one_issue_per_test_case + - update_test_case_catalog_after_issue_creation + - run_serial_issue_worker_and_fresh_verifier_cycles + - update_failure_registry_from_verifier_evidence + - create_or_link_parent_infra_issue_when_signature_threshold_met + - require_target_case_and_regression_bucket_pass_before_release + gates: + - traceability_gate + - surface_qa_gate + - diagnostics_and_build_gate + + - id: phase_refactor + goal: Improve architecture only after behavior is green and all materialized E2E test case issues pass. + policy: refactor_candidate_loop + reference: docs/agents/refactor/refactor-candidate-loop.md + issue_unit: one_github_issue_per_refactor_candidate + scope: safety_refactor_only + skip_when: + - no_refactor_candidates_identified_in_audit + skip_evidence_required: true + regression_strategy: impact_based + e2e_metadata_policy: + may_update: [test_case_catalog_refs, failure_registry_refs] + must_not_change: [expected_outcomes, assertions, acceptance_criteria] + primary_skills: + - improve-codebase-architecture + - karpathy-guidelines + - ai-slop-remover + actions: + - run_refactor_candidate_audit_after_e2e_test_case_loop_passes + - write_refactor_candidate_audit_artifact + - if_no_candidates_skip_with_evidence_note + - score_candidates_by_value_risk_regression_coverage_and_rollback_boundary + - create_one_github_issue_per_approved_refactor_candidate + - label_refactor_issues_with_canonical_and_supplemental_labels + - route_each_refactor_issue_through_issue_worker_verifier_and_release_worker + - require_impact_based_regression_or_all_e2e_buckets_when_uncertain + - update_refactor_audit_and_e2e_metadata_refs_without_expected_behavior_changes + gates: + - traceability_gate + - risk_gate + - diagnostics_and_build_gate + - surface_qa_gate + - review_gate + + - id: documentation_and_tracker_update + goal: Write durable issue-scoped handoff evidence and update docs after verified behavior. + primary_skills: + - grill-with-docs + - write-a-skill + actions: + - update_context_or_adr_when_domain_decisions_changed + - update_operator_docs_when commands_or_gates_changed + - write_issue_scoped_handoff_snapshot + - post_issue_or_pr_evidence_summary + - recommend_next_step_after_each_completed_unit + gates: + - traceability_gate + +stop_conditions: + success: + - all_prd_ready_for_agent_issues_completed + - prd_phase_completeness_audit_passed + - all_issue_prs_have_passed_required_gates + - all_materialized_e2e_test_case_issues_verified_or_released + - refactor_candidate_audit_recorded_and_refactor_issues_completed_or_skipped + - docs_and_tracker_evidence_posted + blocked: + - missing_secret_or_external_access + - unresolved_product_decision + - destructive_action_required + - three_materially_different_fix_attempts_failed + - required_external_skill_or_tool_install_not_approved + - issue_worker_boot_or_result_contract_failure + - prd_phase_completeness_audit_cannot_confirm_coverage_or_publish_more_issues + - pull_request_review_or_merge_requires_human_action + - review_work_fails_with_blocking_issue_that_requires_human_decision + +evidence_templates: + acceptance_criteria_table_columns: + - ac_number + - acceptance_criterion + - evidence_type + - command_or_surface + - result + - notes + issue_completion_comment_sections: + - summary + - compact_ac_matrix + - evidence_packet_refs + - tests_and_e2e_summary + - review_gate + - risks_or_limitations + - next_step_recommendation + post_merge_workspace_hygiene_comment_fields: + - primary_workspace_branch_before + - primary_workspace_branch_after + - dirty_state_detected + - wip_branch_created + - stash_created + - workspace_clean_after + - issue_worktree_removed + - cleanup_status + - blocked_reason + release_summary_override_fields: + - merge_approval_mode + - human_approval_skipped + - override_source + - override_scope + repo_handoff_sections: + - issue_identity + - acceptance_criteria_evidence + - compact_verification_summary + - evidence_packet_refs + - files_changed_summary + - blockers_or_risks + - next_recommended_step + worker_result_object_fields: + - issue + - branch + - status + - summary + - files_changed + - verification + - pr + - blockers + - next_recommended_step + - evidence_packet_refs + - role_boundary + evidence_packet_fields: + - subject + - verifier + - proof_of_separation + - status + - acceptance_criteria_matrix + - gates + - role_boundary + - artifact_manifest + - compact_summary + - next_recommended_step + pr_body_sections: + - summary + - linked_issue + - compact_acceptance_criteria_matrix + - verification_summary_and_artifact_refs + - verifier_manual_qa_result_summary + - review_gate + - rollout_or_followup diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..c2760fb --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,27 @@ +# Domain Docs + +How mattpocock/skills engineering skills should consume this repo's domain documentation when exploring the codebase. + +Source skill setup: + +## Layout + +This repo uses a single-context domain-doc layout. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root if it exists. +- **`docs/adr/`** for ADRs that touch the area about to be changed if it exists. +- **`AGENTS.md`** at the repo root for repo-specific generation and verification rules. + +If any optional domain files don't exist, proceed silently. The producer skill (`grill-with-docs`) can create them lazily when terms or decisions get resolved. + +## Use the glossary's vocabulary + +When output names a domain concept, use the term as defined in `CONTEXT.md` if present. Don't drift to synonyms the glossary explicitly avoids. + +If the concept needed isn't in the glossary yet, note it for `grill-with-docs` instead of inventing a new project vocabulary. + +## Flag ADR conflicts + +If output contradicts an existing ADR, surface it explicitly rather than silently overriding it. diff --git a/docs/agents/e2e/failure-registry.yaml b/docs/agents/e2e/failure-registry.yaml new file mode 100644 index 0000000..6873a88 --- /dev/null +++ b/docs/agents/e2e/failure-registry.yaml @@ -0,0 +1,32 @@ +schema_version: "1.0" +kind: failure_registry +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +purpose: > + Compact registry of stable E2E failure signatures used to detect shared + harness, fixture, environment, or infrastructure failures across test cases. + +update_policy: + owner: main_orchestrator_contract_validation_only + update_when: + - verifier_evidence_packet_reports_failure_signature + - same_signature_reaches_parent_issue_threshold + - blocking_parent_issue_status_changes + - phase_refactor_updates_metadata_refs + +thresholds: + shared_failure_parent_issue_threshold: 2 + max_same_root_cause_repairs: 2 + +entries: [] + +entry_schema: + signature: "" + category: "product|test|fixture|harness|test_data_seed|infra|spec_drift" + affected_test_cases: ["E2E-001"] + evidence_refs: ["docs/agents/evidence/issue--pr-.yaml"] + refactor_issue_refs: [""] + parent_issue: {number: "", url: ""} + disposition: "open|blocked-by-spec|blocked-by-infra|fixed|wontfix" + next_recommended_step: "" diff --git a/docs/agents/e2e/test-case-issue-loop.md b/docs/agents/e2e/test-case-issue-loop.md new file mode 100644 index 0000000..456941c --- /dev/null +++ b/docs/agents/e2e/test-case-issue-loop.md @@ -0,0 +1,254 @@ +# E2E Test Case Issue Loop + +## Status + +Draft workflow spec. This document records the agreed operating model for +turning executable E2E test cases into GitHub issues that can be repaired, +verified, and released by the autonomous development workflow. + +## Purpose + +Use one GitHub issue as the execution unit for one executable E2E test case. +The issue worker may repair product behavior, test code, fixtures, harnesses, +or test data needed for that case. A fresh verifier worker owns final +acceptance. The release worker may merge and close only after verifier-owned +evidence passes. + +This loop replaces the former `phase_full_e2e_and_qa` phase and complements the +existing `per_issue_e2e` phase in `docs/agents/autonomous-development-workflow.yaml`; +it does not make the main agent an E2E executor. + +## Core model + +| Concept | Policy | +| --- | --- | +| Issue unit | One GitHub issue per executable E2E test case. | +| Source of intent | PRD acceptance criteria, bug report, regression, or manual risk. | +| Worker scope | Product, test, fixture, harness, and test data seed repairs for the target case. | +| Final acceptance owner | Fresh verifier worker, never the issue worker or main orchestrator. | +| Release owner | Release worker after verifier evidence passes. | +| Evidence storage | Manifest/index references only; no raw logs, traces, or screenshots in repo docs or issue comments. | + +## Test case timing + +Generate E2E intent during feature specification, then materialize executable +test case issues only after feature implementation has passed its normal +worker/verifier loop. + +During the PRD or feature spec stage, record draft test intent: + +```yaml +e2e_test_intents: + - test_case_id: "E2E-001" + source_ac: "AC1" + scenario: "" + surface: "cli|api|browser|library|sql|static-html" + expected_outcome: "" + risk_level: "high|medium|low" + executable_status: "draft" +``` + +After feature development and PR verification pass, run an E2E materialization +gate. Only then convert draft intent into ready issues with actual setup, +commands, data, expected observations, and evidence requirements. + +## Lifecycle + +1. **Spec intent** + - `grill-with-docs` or PRD drafting identifies user-visible journeys. + - The PRD records `e2e_test_intents` mapped to acceptance criteria. + - These intents are not ready for AFK execution yet. + +2. **Feature implementation** + - Normal issue workers implement the feature through the existing gated + workflow. + - Verifier evidence must pass before E2E test case issues are materialized. + +3. **Materialize test case issues** + - The orchestrator converts executable intents into one GitHub issue per + test case. + - Each issue starts with `needs-triage`, then becomes `ready-for-agent` only + when setup, execution surface, expected outcome, regression bucket, and + evidence requirements are explicit. + - The orchestrator updates `docs/agents/e2e/test-case-catalog.yaml` when the + issue is created. + +4. **Issue worker repairs the case** + - The issue worker runs the target case for implementation feedback. + - If it fails, the worker may repair in-scope product/test/fixture/harness + or test data seed problems. + - The worker must not weaken assertions, change expected outcomes, or claim + final E2E acceptance. + - The worker emits a `worker_result` and, when relevant, opens a PR. + +5. **Verifier reruns in a fresh session** + - A fresh verifier worker reads the issue packet, worker result, PR diff, + and artifact manifest references. + - The verifier reruns the target case and required regression bucket. + - The verifier emits an `evidence_packet` with proof of separation. + +6. **Loop on failure** + - If verifier status is `fail`, the orchestrator routes the same issue back + to an issue worker unless loop caps are exceeded. + - If verifier status is `blocked`, the orchestrator creates or links the + blocking issue and updates labels/catalog state. + +7. **Release** + - The release worker checks verifier evidence, PR mergeability, required + checks, and merge approval policy. + - The release worker does not rerun E2E. + - After merge, it performs post-merge workspace hygiene before closing the + issue. + +## Issue contract additions + +Each E2E test case issue should include this compact contract, either directly +in the issue body or in an issue packet referenced from the issue: + +```yaml +test_case: + id: "E2E-001" + source: {type: "prd_ac|bug_report|regression|manual_risk", ref: ""} + surface: "cli|api|browser|library|sql|static-html" + scenario: "" + setup: "" + execution: "" + expected_outcome: "" + regression_bucket: [""] + evidence_requirements: ["target_case_result", "regression_bucket_result"] + executable_status: "ready" +``` + +## Worker result additions + +E2E issue workers should report repair classification without raw logs: + +```yaml +test_case_repair: + test_case_id: "E2E-001" + attempted_target_case: true + repaired_categories: ["product|test|fixture|harness|test_data_seed"] + failure_signature: "" + assertion_changed: false + expected_outcome_changed: false + final_acceptance_claim: false +``` + +## Evidence packet additions + +Verifier evidence should record target case and regression results: + +```yaml +test_case_verification: + test_case_id: "E2E-001" + target_case: "pass|blocked|fail" + regression_bucket: "pass|blocked|fail" + failure_signature: "" + artifact_manifest_ref: "" + proof_of_separation_required: true +``` + +## Labels + +The existing canonical labels still control triage: + +| Label | E2E meaning | +| --- | --- | +| `needs-triage` | Test intent exists, but issue may not be executable yet. | +| `needs-info` | Setup, expected outcome, source reference, or evidence requirements are missing. | +| `ready-for-agent` | Fully executable E2E test case issue. | +| `ready-for-human` | Requires product or test-design judgment that an AFK worker should not decide. | +| `wontfix` | Test case will not be actioned. | + +E2E-specific labels such as `e2e-test-case`, `blocked-by-spec`, and +`blocked-by-infra` are documented in `docs/agents/triage-labels.md`, but those +labels must not replace the canonical triage labels. + +## Guardrails + +- The main orchestrator validates artifact shape, references, line caps, and + role separation only; it must not run E2E acceptance QA directly. +- The issue worker may run the target case for feedback but must not emit final + acceptance. +- The verifier worker must be a distinct fresh session from the worker and must + record `proof_of_separation`. +- Raw logs, traces, SQL execution logs, screenshots, and verbose manual QA + transcripts are stored outside repo docs and issue comments; repo artifacts + reference them by manifest ID only. +- Worker changes must not weaken assertions or alter expected outcomes. If the + expected behavior is wrong or ambiguous, classify the result as `spec_drift`. +- A `spec_drift` result creates or links a PRD/spec repair issue and marks the + original test case issue blocked by spec. +- Repeated shared harness or infrastructure failures must not spin indefinitely + inside each test case issue. +- Issue execution remains serial until a dedicated concurrency policy exists: + one selected issue, one branch, one fresh worker session, one verifier + session, and one PR at a time. + +## Loop control + +Use these caps unless a workflow run explicitly declares stricter limits: + +```yaml +loop_control: + max_worker_verifier_cycles: 3 + max_same_root_cause_repairs: 2 + shared_failure_parent_issue_threshold: 2 +``` + +When the same stable failure signature appears in two or more test case issues, +the orchestrator should create or link a parent infrastructure/harness issue and +update `docs/agents/e2e/failure-registry.yaml`. + +## Pass condition + +An E2E test case issue can be released only when verifier evidence records: + +```yaml +pass_condition: + target_case: pass + required_regression_bucket: pass + proof_of_separation: pass + artifact_manifest_present: true +``` + +The regression bucket is hybrid: each surface has a default focused regression +set, and the test case may append additional checks. A test case must not remove +the default bucket for its surface. + +## Proposed repo artifacts + +| Path | Purpose | +| --- | --- | +| `docs/agents/e2e/test-case-catalog.yaml` | Operational index of E2E test case issues, status, source refs, PR refs, and latest verifier evidence. | +| `docs/agents/e2e/failure-registry.yaml` | Stable failure signatures, affected test cases, parent issue links, and current disposition. | + +Both artifacts should use `schema_version: "1.0"`, a `kind`, `line_cap`, and +`raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts` when created. + +## Integration points + +Future implementation should update these contracts instead of inventing new +parallel formats: + +- `docs/agents/autonomous-development-workflow.yaml`: run the E2E test case + issue loop after feature verification and before `phase_refactor`. +- `docs/agents/issue-packet-template.yaml`: add optional `test_case` fields for + executable E2E issues. +- `docs/agents/worker-result-template.yaml`: add optional `test_case_repair` + fields. +- `docs/agents/evidence-packet-template.yaml`: add optional + `test_case_verification` fields. +- `docs/agents/triage-labels.md`: document any E2E-specific labels while + preserving canonical triage labels. + +## Rollout plan + +1. Keep this Markdown spec as the source of truth for the design. +2. Add compact YAML schemas to the packet/result/evidence templates. +3. Add `test-case-catalog.yaml` and `failure-registry.yaml` with empty initial + state. +4. Extend the autonomous workflow runbook with the materialization and repair + loop. +5. Trial the loop on one high-risk E2E test case before generating a large batch + of issues. diff --git a/docs/agents/evidence-packet-template.yaml b/docs/agents/evidence-packet-template.yaml new file mode 100644 index 0000000..5af550e --- /dev/null +++ b/docs/agents/evidence-packet-template.yaml @@ -0,0 +1,60 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: + type: "" + issue_number: "" + pr_number: "" + phase: "" + branch: "" + sha: "" + +verifier: + actor: "" + actor_role: "pr_verifier" + verifier_session_id: "" + started_at: "" + completed_at: "" + +proof_of_separation: + worker_result_ref: "" + worker_actor: "" + worker_session_id: "" + verifier_actor: "" + verifier_session_id: "" + verifier_is_distinct_from_worker: true + # false allows other compact refs (issue packet, diff, checkpoint, PR page), never raw logs/transcripts. + verifier_read_worker_result_only: true +status: "" + +test_case_verification: {applies: false, test_case_id: "", target_case: "", regression_bucket: "", failure_signature: "", artifact_manifest_ref: ""} + +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "", evidence_ref: "", note: ""} + +gates: + diagnostics_and_build_gate: {status: "", evidence_ref: ""} + surface_qa_gate: {status: "", evidence_ref: ""} + review_gate: {status: "", evidence_ref: ""} + security_gate: {status: "", evidence_ref: ""} + +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true + +artifact_manifest: + bundle_ref: "" + retention: "" + items: + - {id: "", kind: "", executor_role: "pr_verifier", path: "", sha256: "", description: ""} + +compact_summary: + outcome: "" + automated_checks: "" + manual_qa: "" + risks_or_limitations: [""] + +next_recommended_step: "" diff --git a/docs/agents/evidence/issue-16-pr-17.yaml b/docs/agents/evidence/issue-16-pr-17.yaml new file mode 100644 index 0000000..6813b0a --- /dev/null +++ b/docs/agents/evidence/issue-16-pr-17.yaml @@ -0,0 +1,58 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: + type: issue_pr + issue_number: "16" + pr_number: "17" + phase: "" + branch: agent/issue-16-context-budget-release-worker + +verifier: + role: pr_verifier + session: fresh_verifier_not_worker_session + worker_session_excluded: ses_1fedd217cffeul2do36cJT0HPe + +proof_of_separation: + verifier_is_not_worker: true + worker_session_id: ses_1fedd217cffeul2do36cJT0HPe + verifier_session: fresh_pr_verifier + main_agent_ran_no_qa: true + +status: pass + +acceptance_criteria_matrix: + - {ac_id: "AC1", status: pass, evidence_ref: "scripts/agent_context_budget_check.py + docs/agents/runtime/context-checkpoint.yaml", note: "45/50 gates and checkpoint-only resume confirmed in workflow and checkpoint template; script gate passes."} + - {ac_id: "AC2", status: pass, evidence_ref: "docs/agents/autonomous-development-workflow.yaml#release_worker_policy + docs/agents/issue-tracker.md", note: "release_worker_policy defines verifier-pass, mergeability, required-checks, and human-approval gates; issue-tracker.md references release_worker and human merge approval."} + - {ac_id: "AC3", status: pass, evidence_ref: "scripts/agent_context_budget_check.py line-cap checks", note: "All 6 artifact files within declared caps (65/80, 64/80, 58/60, 41/80, 67/80, 35/35); index-only raw evidence policy present in workflow and templates."} + - {ac_id: "AC4", status: pass, evidence_ref: "scripts/agent_context_budget_check.py", note: "py_compile pass; script gate pass (6 caps + 3 policy files checked); script reads only text/line counts, no raw logs."} + - {ac_id: "AC5", status: pass, evidence_ref: "docs/agents/autonomous-development-workflow.yaml#priority_bridge + #main_agent_contract_validation_only", note: "main_agent_contract_validation_only and main_agent_direct_issue_or_phase_qa forbidden list remain explicit and unchanged."} + +gates: + diagnostics_and_build_gate: {status: pass, evidence_ref: "py_compile scripts/agent_context_budget_check.py — pass"} + surface_qa_gate: {status: pass, evidence_ref: "python3 scripts/agent_context_budget_check.py — context budget gate: pass"} + review_gate: {status: pass, evidence_ref: "policy doc review — all required text strings present"} + security_gate: {status: not_applicable, evidence_ref: "doc/script only; no secrets, credentials, or external calls"} + +role_boundary: + acceptance_qa_owner: pr_verifier + main_agent_ran_qa: false + worker_self_checks_are_not_final: true + +artifact_manifest: + bundle_ref: "local verifier run — no CI artifact bundle" + retention: "" + items: + - {id: "script-gate-run", kind: "summary", executor_role: "pr_verifier", path: "scripts/agent_context_budget_check.py", sha256: "", description: "Verifier ran py_compile and script gate; both pass"} + - {id: "line-cap-check", kind: "summary", executor_role: "pr_verifier", path: "docs/agents/evidence/issue-16-pr-17.yaml", sha256: "", description: "wc -l confirmed all 6 artifacts within declared caps"} + - {id: "policy-text-check", kind: "summary", executor_role: "pr_verifier", path: "docs/agents/autonomous-development-workflow.yaml", sha256: "", description: "grep confirmed all 8 required policy strings present"} + +compact_summary: + outcome: "All AC1-AC5 pass; PR #17 is ready for human review and merge." + automated_checks: "py_compile pass; script gate pass (6 caps, 3 policy files); all required policy strings confirmed present." + manual_qa: "Doc policy review confirms release_worker, checkpoint, and main-agent boundary policies are correctly defined and consistent." + risks_or_limitations: ["evidence-packet line count is 58/60 — within cap but close; future additions should trim."] + +next_recommended_step: "Main agent validates this evidence packet schema/refs, then spawns a release_worker to merge PR #17 and close issue #16 after human merge approval." diff --git a/docs/agents/evidence/issue-2-pr-8.yaml b/docs/agents/evidence/issue-2-pr-8.yaml new file mode 100644 index 0000000..79206ce --- /dev/null +++ b/docs/agents/evidence/issue-2-pr-8.yaml @@ -0,0 +1,42 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces +legacy_note: {historical_reconstruction: true, pre_enforcement: true, limitation: "PR #8 merged before fresh worker/verifier packet enforcement; this packet indexes durable historical refs only."} +subject: {type: "issue_pr", issue_number: "2", pr_number: "8", phase: "dashboard-builder-mvp", branch: "agent/issue-2-governed-query-evidence-path", sha: "014a3d991d50547256e979435d79ac3551b39f6a"} +verifier: {actor: "historical-pr-summary", actor_role: "historical_review_summary", verifier_session_id: "github-pr-8-history", started_at: "2026-05-07T01:42:47Z", completed_at: "2026-05-07T01:42:47Z"} +proof_of_separation: + worker_result_ref: "historical: none recorded before workflow packet enforcement" + worker_actor: "unrecorded-pre-enforcement-worker" + worker_session_id: "unrecorded" + verifier_actor: "historical-pr-summary" + verifier_session_id: "github-pr-8-history" + verifier_is_distinct_from_worker: false + verifier_read_worker_result_only: false +status: "pass" +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "pr-8-body:acceptance-criteria + skill_scripts/governed_query.py", note: "One SQL statement or refusal path was documented in PR #8 and is present in merged governed query code."} + - {ac_id: "AC2", status: "pass", evidence_ref: "pr-8-body:acceptance-criteria + tests/skill_scripts/test_governed_query.py + tests/skill_scripts/test_sql2000_guard.py", note: "Non-SELECT, metadata-invalid, and SQL Server 2000-incompatible SQL were documented as rejected before execution."} + - {ac_id: "AC3", status: "pass", evidence_ref: "pr-8-body:verification + skill_scripts/governed_query.py", note: "PR #8 recorded read-only execution against the test boundary and the merged code still enforces DB_ENV=test unless explicitly overridden."} + - {ac_id: "AC4", status: "pass", evidence_ref: "pr-8-body:acceptance-criteria + skill_scripts/governed_query.py", note: "Historical evidence JSON covered prompt, SQL, statuses, timestamp, returned columns, row count, sample rows, and aggregate checks."} + - {ac_id: "AC5", status: "pass", evidence_ref: "pr-8-body:acceptance-criteria + tests/skill_scripts/test_governed_query.py", note: "Merged public API keeps unapproved results blocked from chart-spec creation until explicit approval."} + - {ac_id: "AC6", status: "pass", evidence_ref: "pr-8-body:verification + tests/skill_scripts/test_governed_query.py", note: "Historical pytest coverage referenced fake DB/LLM paths and execution-result validation for the governed path."} +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "pr-8-body:verification"} + surface_qa_gate: {status: "pass", evidence_ref: "pr-8-body:manual-qa"} + review_gate: {status: "pass", evidence_ref: "https://github.com/paulpai0412/wferp/pull/8"} + security_gate: {status: "pass", evidence_ref: "merged-diff-review:read-only-governed-query-scope"} +role_boundary: {acceptance_qa_owner: "historical_pr_review_summary", main_agent_ran_issue_qa: false, worker_self_checks_are_not_final_acceptance: true} +artifact_manifest: + bundle_ref: "https://github.com/paulpai0412/wferp/pull/8" + retention: "Repo stores only compact historical refs; raw logs were not preserved locally." + items: + - {id: "pr-8-body:acceptance-criteria", kind: "summary", executor_role: "historical_review_summary", path: "https://github.com/paulpai0412/wferp/pull/8", sha256: "", description: "PR body recorded AC coverage and verification summaries for issue #2."} + - {id: "merged-files:governed-query", kind: "summary", executor_role: "historical_merge_state", path: "skill_scripts/governed_query.py", sha256: "", description: "Merged governed query code defines evidence payload, approval states, and chart-spec gating."} + - {id: "merged-tests:governed-query", kind: "summary", executor_role: "historical_merge_state", path: "tests/skill_scripts/test_governed_query.py", sha256: "", description: "Merged tests cover happy path, refusals, approval gating, and JSON-safe sample rows."} +compact_summary: + outcome: "Issue #2 / PR #8 now has compact repo-local traceability reconstructed from GitHub issue/PR text and merged repo state." + automated_checks: "Historical PR #8 recorded pytest, governed CLI QA, and changed-file LSP summaries; this packet does not claim a fresh rerun." + manual_qa: "Historical PR #8 manual CLI QA recorded happy-path evidence JSON and refusal-path denial without SQL execution." + risks_or_limitations: ["Worker/verifier session ids and raw logs were not preserved before workflow enforcement, so proof-of-separation is historical-only."] +next_recommended_step: "Use this packet plus docs/agents/handoffs/issue-2.yaml to finish PRD #1 completeness audit honestly." diff --git a/docs/agents/evidence/issue-20-pr-21.yaml b/docs/agents/evidence/issue-20-pr-21.yaml new file mode 100644 index 0000000..0e834cc --- /dev/null +++ b/docs/agents/evidence/issue-20-pr-21.yaml @@ -0,0 +1,48 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: {type: "issue_pr", issue_number: "20", pr_number: "21", phase: "", branch: "agent/issue-20-reconstruct-governed-query-traceability", sha: "92e8b1772beaae339ced49895354d8b9378152d1"} +verifier: {actor: "Sisyphus-Junior", actor_role: "pr_verifier", verifier_session_id: "pr-verifier:issue-20-pr-21-2026-05-07", started_at: "2026-05-07T19:10:00+08:00", completed_at: "2026-05-07T19:10:00+08:00"} + +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-20.yaml" + worker_actor: "Sisyphus-Junior" + worker_session_id: "worker-result:issue-20" + verifier_actor: "Sisyphus-Junior" + verifier_session_id: "pr-verifier:issue-20-pr-21-2026-05-07" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: false + +status: "pass" + +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "docs/agents/issue-packets/issue-20.yaml", note: "Issue #20 packet scopes retrospective repair to merged PR #8 and stays within the declared line cap."} + - {ac_id: "AC2", status: "pass", evidence_ref: "docs/agents/handoffs/issue-2.yaml", note: "Issue #2 handoff maps all historical ACs to durable compact refs without inlining raw logs."} + - {ac_id: "AC3", status: "pass", evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml", note: "Issue #2 evidence packet records status, AC coverage, gates, artifact refs, and explicit pre-enforcement limitations."} + - {ac_id: "AC4", status: "pass", evidence_ref: "docs/agents/runtime/context-checkpoint.yaml", note: "Checkpoint now points PRD #1 completeness work back to the reconstructed issue #2 refs."} + - {ac_id: "AC5", status: "pass", evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml + docs/agents/handoffs/issue-2.yaml + docs/agents/worker-results/issue-20.yaml", note: "Artifacts stay compact and index-only; no raw logs, traces, screenshots, or transcripts were added."} + +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier:python3 scripts/agent_context_budget_check.py + py_compile + lsp_diagnostics"} + surface_qa_gate: {status: "pass", evidence_ref: "verifier doc review of issue #20 packet, issue #2 handoff/evidence, worker result, checkpoint, and PR #21 diff"} + review_gate: {status: "pass", evidence_ref: "lightweight verifier diff review against issue #20 scope and workflow policy"} + security_gate: {status: "pass", evidence_ref: "doc/script diff review: no secrets, credentials, or new write/network surface"} + +role_boundary: {acceptance_qa_owner: "pr_verifier", main_agent_ran_issue_qa: false, worker_self_checks_are_not_final_acceptance: true} + +artifact_manifest: + bundle_ref: "local verifier run for PR #21" + retention: "Repo docs keep index-only refs; raw logs/traces remain outside repo docs." + items: + - {id: "verifier-script-gate", kind: "summary", executor_role: "pr_verifier", path: "scripts/agent_context_budget_check.py", sha256: "", description: "Context-budget gate and py_compile passed on the PR branch."} + - {id: "verifier-doc-review", kind: "summary", executor_role: "pr_verifier", path: "docs/agents/evidence/issue-20-pr-21.yaml", sha256: "", description: "Diff and artifact review confirmed honest retrospective traceability plus explicit historical limits."} + +compact_summary: + outcome: "Issue #20 passes as a workflow self-repair slice; reconstructed issue #2 refs are honest and sufficient for the PRD #1 completeness audit to resume." + automated_checks: "Context-budget script, py_compile, and changed-file diagnostics passed; relevant compact artifacts stayed within their declared caps." + manual_qa: "Verifier read the issue #20 packet, issue #2 handoff/evidence, worker result, checkpoint, and PR #21 diff; no raw logs were inlined and historical limits stayed explicit." + risks_or_limitations: ["Issue #2 remains historical-only traceability; this packet does not claim fresh worker/verifier proof or fresh product verification for PR #8."] + +next_recommended_step: "Use docs/agents/handoffs/issue-2.yaml and docs/agents/evidence/issue-2-pr-8.yaml to resume the PRD #1 completeness audit." diff --git a/docs/agents/evidence/issue-28-pr-29.yaml b/docs/agents/evidence/issue-28-pr-29.yaml new file mode 100644 index 0000000..5e969aa --- /dev/null +++ b/docs/agents/evidence/issue-28-pr-29.yaml @@ -0,0 +1,51 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: {type: "issue_pr", issue_number: "28", pr_number: "29", branch: "agent/issue-28-ac17-e2e-test-case", sha: "0b31b289c633f8a80c144a7dd435ebf754df05fa"} +verifier: {actor: "Sisyphus-Junior", actor_role: "pr_verifier", verifier_session_id: "pr-verifier:issue-28-pr-29-2026-05-09", started_at: "2026-05-09T17:20:00+08:00", completed_at: "2026-05-09T17:20:00+08:00"} + +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-28.yaml" + worker_actor: "Sisyphus-Junior" + worker_session_id: "worker-result:issue-28" + verifier_actor: "Sisyphus-Junior" + verifier_session_id: "pr-verifier:issue-28-pr-29-2026-05-09" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true + +status: "pass" + +test_case_verification: + applies: true + test_case_id: "E2E-001" + target_case: "pass" + regression_bucket: "pass" + failure_signature: "none" + artifact_manifest_ref: "docs/agents/evidence/issue-28-pr-29.yaml#artifact_manifest" + proof_of_separation_required: true + +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier:python3 -m pytest tests/skill_scripts/test_dashboard_builder_e2e.py tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q"} + surface_qa_gate: {status: "pass", evidence_ref: "verifier reran E2E-001 on the CLI/library surface and confirmed query-approval + final-approval refusal gates in tests/skill_scripts/test_dashboard_builder_e2e.py"} + review_gate: {status: "pass", evidence_ref: "verifier reviewed PR #29 diff against issue #28 AC1-AC4 and found no goal, security, or role-boundary violations"} + security_gate: {status: "pass", evidence_ref: "verifier grep confirmed no pyright/type-ignore/@ts-ignore/@ts-expect-error/as any patterns in tests/skill_scripts/test_dashboard_builder_e2e.py"} + +role_boundary: {acceptance_qa_owner: "pr_verifier", main_agent_ran_issue_qa: false, worker_self_checks_are_not_final_acceptance: true} + +artifact_manifest: + bundle_ref: "local verifier run for PR #29" + retention: "Repo docs stay index-only; raw pytest output remains outside repo docs/comments." + items: + - {id: "compact-refs", kind: "summary", executor_role: "pr_verifier", path: "docs/agents/issue-packets/issue-28.yaml + docs/agents/worker-results/issue-28.yaml + docs/agents/handoffs/issue-28.yaml + docs/agents/e2e/test-case-catalog.yaml", sha256: "", description: "Compact workflow refs and role boundaries validated without importing worker transcripts."} + - {id: "target-case", kind: "summary", executor_role: "pr_verifier", path: "tests/skill_scripts/test_dashboard_builder_e2e.py", sha256: "", description: "AC17 target case covers prompt -> publish -> revoke and includes pre-approval refusal assertions."} + - {id: "pr-review", kind: "summary", executor_role: "pr_verifier", path: "https://github.com/paulpai0412/wferp/pull/29", sha256: "", description: "PR metadata/diff reviewed; verifier-owned artifacts only were updated after acceptance rerun passed 46/46."} + +compact_summary: + outcome: "PR #29 passes fresh verifier acceptance for issue #28; E2E-001 and its required regression bucket reran cleanly on the declared CLI/library surface." + automated_checks: "Verifier-owned pytest bucket passed 46/46; target test still contains query-approval and final-approval refusal gates with no new type-suppression patterns." + manual_qa: "Compact refs and PR diff confirm AC1-AC4 coverage, compact-doc policy compliance, and preserved worker/verifier role separation." + risks_or_limitations: ["This packet claims CLI/library acceptance only; it does not claim browser UI coverage."] + +next_recommended_step: "Hand PR #29 to release_worker or human merge review for mergeability/approval-policy evaluation; do not merge from this verifier step." diff --git a/docs/agents/evidence/issue-3-pr-11.yaml b/docs/agents/evidence/issue-3-pr-11.yaml new file mode 100644 index 0000000..8937f84 --- /dev/null +++ b/docs/agents/evidence/issue-3-pr-11.yaml @@ -0,0 +1,96 @@ +schema_version: "1.0" +kind: evidence_packet + +subject: + type: "issue_pr" + issue_number: "3" + pr_number: "11" + phase: "dashboard-builder-mvp" + branch: "agent/issue-3-chart-spec-authoring-and-validation" + sha: "104a171" + +legacy_note: + pre_enforcement: true + not_proof_of_fresh_verifier_separation: true + reason: "Created before the P0 autonomous workflow role-separation packet requirements were enforced." + +verifier: + actor: "historical-verifier-summary" + started_at: "2026-05-06T00:00:00+08:00" + completed_at: "2026-05-06T00:00:00+08:00" + +status: "pass" + +acceptance_criteria_matrix: + - ac_id: "AC1" + status: "pass" + evidence_ref: "test_table_chart_spec_requires_approved_query_result" + note: "Approved GovernedQueryResult is accepted and unapproved input is rejected." + - ac_id: "AC2" + status: "pass" + evidence_ref: "tests/skill_scripts/test_chart_spec.py" + note: "Public authoring payloads cover table, KPI card, bar, and line chart types." + - ac_id: "AC3" + status: "pass" + evidence_ref: "tests/skill_scripts/test_chart_spec.py" + note: "Validation covers chart type, mappings, sorting, title, color, number format, and date format." + - ac_id: "AC4" + status: "pass" + evidence_ref: "test_invalid_mappings_are_rejected_with_clear_errors" + note: "Invalid mappings and direct public ChartSpec revalidation paths are rejected with clear errors." + - ac_id: "AC5" + status: "pass" + evidence_ref: "manual-library-driver:chart-preview" + note: "Preview rendering returns table rows, KPI value/label, and ordered bar/line data." + - ac_id: "AC6" + status: "pass" + evidence_ref: "command:pytest-chart-spec" + note: "Focused chart spec tests passed for valid and invalid MVP specs." + +gates: + diagnostics_and_build_gate: + status: "pass" + evidence_ref: "command:py-compile-and-lsp" + surface_qa_gate: + status: "pass" + evidence_ref: "manual-library-driver:chart-preview" + review_gate: + status: "pass" + evidence_ref: "pr:https://github.com/paulpai0412/wferp/pull/11" + security_gate: + status: "pass" + evidence_ref: "diff-review:no-secrets-or-write-sql-surface" + +artifact_manifest: + bundle_ref: "local-session-evidence:commit-104a171" + retention: "historical summary retained in repo; raw session output not duplicated in main agent context" + items: + - id: "command:py-compile-and-lsp" + kind: "summary" + path: "docs/agents/handoffs/issue-3.yaml" + sha256: "" + description: "Changed-file diagnostics and py_compile passed." + - id: "command:pytest-chart-spec" + kind: "summary" + path: "docs/agents/handoffs/issue-3.yaml" + sha256: "" + description: "Focused chart spec pytest run passed with 9 tests." + - id: "command:pytest-skill-scripts" + kind: "summary" + path: "docs/agents/handoffs/issue-3.yaml" + sha256: "" + description: "Relevant skill_scripts suite passed with 74 tests." + - id: "manual-library-driver:chart-preview" + kind: "driver-output" + path: "docs/agents/handoffs/issue-3.yaml" + sha256: "" + description: "Library driver exercised chart authoring, preview rendering, and refusal paths." + +compact_summary: + outcome: "Issue #3 chart spec authoring and validation passed all recorded acceptance criteria." + automated_checks: "LSP diagnostics, py_compile, focused chart spec tests, and relevant skill_scripts tests passed." + manual_qa: "Library driver rendered all four chart types and observed expected refusal paths." + risks_or_limitations: + - "Library-only MVP surface; dashboard UI and persistence were out of scope." + +next_recommended_step: "Use PR #11 as completed historical evidence and keep future raw logs in artifact bundles." diff --git a/docs/agents/evidence/issue-31-pr-35.yaml b/docs/agents/evidence/issue-31-pr-35.yaml new file mode 100644 index 0000000..b54ea96 --- /dev/null +++ b/docs/agents/evidence/issue-31-pr-35.yaml @@ -0,0 +1,46 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces +subject: {type: "issue_pr", issue_number: "31", pr_number: "35", phase: "", branch: "agent/issue-31-deterministic-actmk-sql-correctness", sha: "60ea00d068699b7e3341a3a1ecd6d71bc41b1660"} +verifier: {actor: "OpenCode-gpt-5.5", actor_role: "pr_verifier", verifier_session_id: "ses_1f312ad8effe8LJOHOULophmgX", started_at: "2026-05-09T21:11:00+08:00", completed_at: "2026-05-09T21:31:35+08:00"} +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-31.yaml" + worker_actor: "OpenCode-gpt-5.4" + worker_session_id: "worker-result:issue-31" + verifier_actor: "OpenCode-gpt-5.5" + verifier_session_id: "ses_1f312ad8effe8LJOHOULophmgX" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true +status: "pass" +test_case_verification: {applies: true, test_case_id: "E2E-002", target_case: "pass", regression_bucket: "pass", failure_signature: "none", artifact_manifest_ref: "local-verifier:issue-31-pr-35"} +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "test_db/init/01_create_wferp_test.sql + verifier-seed-check", note: "Seeded ACTMK contains three 2026 rows totaling MK006=450000 plus one 2025 contrast row."} + - {ac_id: "AC2", status: "pass", evidence_ref: "verifier:pytest-focused-bucket + verifier:cli-governed-query", note: "DB_ENV=test execution returned MK002/MK006, row_count=3, and sum(MK006)=450000."} + - {ac_id: "AC3", status: "pass", evidence_ref: "tests/skill_scripts/test_sql_correctness_e2e.py + verifier:pytest-focused-bucket", note: "2025 contrast row exists in fixture coverage and is excluded from the 2026 result set."} + - {ac_id: "AC4", status: "pass", evidence_ref: "skill_scripts/sql_router.py + skill_scripts/database_client.py + verifier:non-test-guardrail", note: "DB_ENV=test/read-only guardrails remain active and assertions are result-based, not exact SQL equality."} + - {ac_id: "AC5", status: "pass", evidence_ref: "docs/agents/worker-results/issue-31.yaml + docs/agents/evidence/issue-31-pr-35.yaml", note: "Worker and verifier artifacts stay compact/index-only with no raw logs or transcripts."} +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier:lsp-database_client-and-e2e + py_compile-changed-python + pytest-focused-bucket"} + surface_qa_gate: {status: "pass", evidence_ref: "verifier:cli_generate_select-governed-query-json under DB_ENV=test plus DB_ENV_NOT_TEST guardrail check"} + review_gate: {status: "pass", evidence_ref: "PR#35 open, not draft, MERGEABLE/CLEAN, no configured status checks; verifier diff/goal review passed"} + security_gate: {status: "pass", evidence_ref: "verifier:diff-and-secret-pattern-review; only documented test DB credentials/placeholders observed"} +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true +artifact_manifest: + bundle_ref: "local-verifier:issue-31-pr-35" + retention: "index-only refs in repo docs; raw command output not committed" + items: + - {id: "pr-metadata", kind: "summary", executor_role: "pr_verifier", path: "gh pr view 35", sha256: "", description: "PR open, clean mergeable, head sha recorded, no CI checks configured."} + - {id: "db-seed", kind: "summary", executor_role: "pr_verifier", path: "test_db/init/01_create_wferp_test.sql", sha256: "", description: "Verifier reseeded the running wferp-mssql-test container."} + - {id: "focused-tests", kind: "summary", executor_role: "pr_verifier", path: "tests/skill_scripts/test_sql_correctness_e2e.py + regression bucket", sha256: "", description: "DB-backed E2E-002 and focused regression bucket passed."} + - {id: "cli-surface", kind: "summary", executor_role: "pr_verifier", path: "skill_scripts/cli_generate_select.py", sha256: "", description: "Governed-query CLI surface returned expected 2026 rows and aggregate in DB_ENV=test."} + - {id: "guardrails", kind: "summary", executor_role: "pr_verifier", path: "skill_scripts/sql_router.py + skill_scripts/database_client.py", sha256: "", description: "Non-test execution remains blocked without explicit override."} +compact_summary: + outcome: "PR #35 satisfies issue #31 / E2E-002 verifier acceptance criteria." + automated_checks: "Seed, DB-backed focused pytest bucket, py_compile, LSP targeted diagnostics, CLI governed query, and non-test guardrail checks passed." + manual_qa: "SQL/CLI surface confirmed 2026 happy path and 2025 exclusion semantics; no browser surface applies." + risks_or_limitations: ["GitHub PR has no configured CI checks; local verifier checks are the recorded gate evidence.", "Repo-wide skill_scripts LSP still has pre-existing typing warnings outside the blocking changed-file scope."] +next_recommended_step: "Issue #31 was released via PR #35; continue serially to the next unblocked E2E issue." diff --git a/docs/agents/evidence/issue-33-pr-37.yaml b/docs/agents/evidence/issue-33-pr-37.yaml new file mode 100644 index 0000000..23b168b --- /dev/null +++ b/docs/agents/evidence/issue-33-pr-37.yaml @@ -0,0 +1,58 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces +subject: + type: "issue_pr" + issue_number: "33" + pr_number: "37" + phase: "" + branch: "agent/issue-33-e2e-query-analyst-approval-gate-before-chart-spe" + sha: "b695e76f55a249e718930d2e9b94f3b37eb94ea3" +verifier: + actor: "Sisyphus-pr_verifier" + actor_role: "pr_verifier" + verifier_session_id: "verifier:issue-33-pr-37-20260510-2359" + started_at: "2026-05-10T23:59:00+08:00" + completed_at: "2026-05-11T00:05:00+08:00" +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-33.yaml" + worker_actor: "Sisyphus-issue_worker" + worker_session_id: "worker:issue-33-20260510-2310" + verifier_actor: "Sisyphus-pr_verifier" + verifier_session_id: "verifier:issue-33-pr-37-20260510-2359" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: false +status: "pass" +failure_classification: {kind: "none", retryable: false, routed_to: "release_worker", root_cause_signature: "none"} +test_case_verification: {applies: true, test_case_id: "E2E-003", target_case: "pass", regression_bucket: "pass", failure_signature: "none", artifact_manifest_ref: "local-verifier:issue-33-pr-37-b695e76"} +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "surface-driver", note: "Governed query for 查詢2026年的工程預算明細 returned MK002/MK006 with 3 deterministic rows."} + - {ac_id: "AC2", status: "pass", evidence_ref: "surface-driver", note: "Both Chart Spec entry points refused the unapproved result with QUERY_RESULT_NOT_APPROVED."} + - {ac_id: "AC3", status: "pass", evidence_ref: "surface-driver", note: "After approval, bar Chart Spec/draft succeeded with x=MK002, y=MK006, and MK006 currency formatting."} + - {ac_id: "AC4", status: "pass", evidence_ref: "target-case + issue-33-pr-37.yaml", note: "Verifier kept refusal/success evidence as compact refs only; no raw logs were copied into repo docs."} + - {ac_id: "AC5", status: "pass", evidence_ref: "regression-bucket", note: "Focused dashboard-builder regression bucket passed 25/25 with existing governed-query/chart/dashboard assertions intact."} +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "target-case 2/2 + regression bucket 25/25 + py_compile clean + lsp_diagnostics clean on changed files"} + surface_qa_gate: {status: "pass", evidence_ref: "surface-driver observed both refusal-before-approval and approved MK006 happy path through the library surface"} + review_gate: {status: "pass", evidence_ref: "PR #37 isDraft=false, mergeable=MERGEABLE, mergeStateStatus=CLEAN; diff limited to E2E-003 test plus compact worker metadata/docs"} + security_gate: {status: "pass", evidence_ref: "Diff review found no secrets, no new script/network surface, and no bypass of required approval gates"} +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true +artifact_manifest: + bundle_ref: "local-verifier:issue-33-pr-37-b695e76" + retention: "index-only refs in repo docs; raw verifier artifacts stay under /tmp/opencode/wferp-issue33-artifacts" + items: + - {id: "pr-metadata", kind: "summary", executor_role: "pr_verifier", path: "gh pr view 37", sha256: "", description: "PR open, not draft, MERGEABLE/CLEAN, no configured CI status checks."} + - {id: "target-case", kind: "log", executor_role: "pr_verifier", path: "/tmp/opencode/wferp-issue33-artifacts/target-case-b695e76.log", sha256: "149e983c325e1215d8fe411fd58fb9c0fa38bc5dfa119c4a6a92d44ee61e3e10", description: "Independent pytest target case passed 2/2."} + - {id: "regression-bucket", kind: "log", executor_role: "pr_verifier", path: "/tmp/opencode/wferp-issue33-artifacts/regression-bucket-b695e76.log", sha256: "514c0c2f8b12583c82d1ab0eee136d9dd023fe6435e73e4dc59b08a1d5a61c18", description: "Focused dashboard-builder regression bucket passed 25/25."} + - {id: "changed-file-diagnostics", kind: "log", executor_role: "pr_verifier", path: "/tmp/opencode/wferp-issue33-artifacts/py-compile-b695e76.log + lsp_diagnostics", sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", description: "py_compile clean; lsp_diagnostics returned no issues for changed test and compact YAML files."} + - {id: "surface-driver", kind: "summary", executor_role: "pr_verifier", path: "/tmp/opencode/wferp-issue33-artifacts/surface-driver-b695e76.json", sha256: "f62af684a858881bb976f7958dc61c7e1c686fdfaf47e3db8c9343bae58b8e84", description: "Independent library driver observed refusal before approval and approved MK006 chart creation."} +compact_summary: + outcome: "PR #37 satisfies issue #33 / E2E-003 acceptance criteria at HEAD b695e76." + automated_checks: "Independent verifier reran the target case (2/2), focused regression bucket (25/25), py_compile, and changed-file diagnostics successfully." + manual_qa: "Library-surface driver reproduced the real refusal path before Analyst Approval and the approved MK006 bar-chart path after approval." + risks_or_limitations: ["GitHub PR #37 has no configured CI status checks; local verifier artifacts are the recorded gate evidence.", "Scope is library-only for this issue; no browser surface applies."] +next_recommended_step: "Hand PR #37 to release_worker for merge-gate evaluation; bypass_approval may skip human merge approval only after this verifier pass is honored." diff --git a/docs/agents/evidence/issue-4-pr-13.yaml b/docs/agents/evidence/issue-4-pr-13.yaml new file mode 100644 index 0000000..65b3d14 --- /dev/null +++ b/docs/agents/evidence/issue-4-pr-13.yaml @@ -0,0 +1,122 @@ +schema_version: "1.0" +kind: evidence_packet +legacy_note: + pre_enforcement: true + limitation: "Fresh verifier separation was recorded, but this full-form evidence packet predates compact line-cap enforcement and is retained as historical detail." + +subject: + type: "issue_pr" + issue_number: "4" + pr_number: "13" + phase: "dashboard-builder-mvp" + branch: "agent/issue-4-design-template-catalog-and-selection" + sha: "828e87a" + +verifier: + actor: "pr_verifier (fresh post-enforcement session)" + actor_role: "pr_verifier" + verifier_session_id: "opencode-session-pr13-verify-2026-05-07" + started_at: "2026-05-07T00:00:00+08:00" + completed_at: "2026-05-07T00:00:00+08:00" + +proof_of_separation: + worker_result_ref: "docs/agents/evidence/issue-4-pr-13.yaml" + worker_actor: "Hephaestus (pre-enforcement worker)" + worker_session_id: "pre-enforcement-worker-pr13" + verifier_actor: "pr_verifier (fresh post-enforcement session)" + verifier_session_id: "opencode-session-pr13-verify-2026-05-07" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true + +status: "pass" + +acceptance_criteria_matrix: + - ac_id: "AC1" + status: "pass" + evidence_ref: "test_catalog_presents_deterministic_generic_design_template_options PASSED; list_design_template_options() returns 3 curated templates with DESIGN.md-style tokens" + note: "Three generic templates (executive_contrast, soft_operations, ledger_focus) confirmed deterministic across two calls." + - ac_id: "AC2" + status: "pass" + evidence_ref: "test_catalog_options_do_not_use_source_brand_names_as_official_names PASSED; checked against {apple,stripe,linear,notion,vercel,shopify,slack,github,voltagent}" + note: "No source brand names appear in any official template name." + - ac_id: "AC3" + status: "pass" + evidence_ref: "test_allowed_design_template_can_be_selected_for_dashboard_preview PASSED; test_invalid_design_template_selection_is_refused_with_clear_error PASSED; manual driver refusal check PASSED" + note: "select_design_template() returns DesignTemplateSelection; invalid IDs raise DESIGN_TEMPLATE_NOT_ALLOWED." + - ac_id: "AC4" + status: "pass" + evidence_ref: "test_allowed_design_template_can_be_selected_for_dashboard_preview PASSED; apply_design_template_to_canvas_preview() returns {template, canvas}; manual driver apply check PASSED" + note: "Template payload and canvas payload both present in preview output." + - ac_id: "AC5" + status: "pass" + evidence_ref: "test_switching_design_templates_replaces_the_active_template PASSED; executive_contrast absent from preview after switch to ledger_focus" + note: "Switching replaces; no blending observed." + - ac_id: "AC6" + status: "pass" + evidence_ref: "7/7 tests in tests/skill_scripts/test_design_template_catalog.py PASSED; covers determinism, brand-name exclusion, selection, apply, switching, invalid refusal, dict-refusal, canonicalization" + note: "All required behavioral dimensions covered by dedicated test file." + +gates: + diagnostics_and_build_gate: + status: "pass" + evidence_ref: "py_compile PASS on skill_scripts/design_template_catalog.py and test file; pytest import succeeded; 89/89 tests executed without import error; LSP server unavailable but py_compile + runtime import + full pytest suite constitute sufficient build evidence for a pure-Python library-only slice" + surface_qa_gate: + status: "pass" + evidence_ref: "manual public API driver: 6/6 checks PASSED (list options, select+apply, switch, invalid-id refusal, dict-selection refusal, forged-selection canonicalization); isolated worktree /tmp/opencode/pr13-verify" + review_gate: + status: "pass" + evidence_ref: "diff reviewed: 4 new files only (design_template_catalog.py, test_design_template_catalog.py, evidence yaml, handoff yaml); no modifications to existing files; PR #14 merge-base check confirms zero file overlap; GitHub mergeable=MERGEABLE" + security_gate: + status: "pass" + evidence_ref: "new module imports only dataclasses and dashboard_canvas; no exec/eval/subprocess/os/open/network calls; frozen dataclasses prevent mutation; no secrets or user-controlled code execution paths" + +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true + +artifact_manifest: + bundle_ref: "local/isolated-worktree:/tmp/opencode/pr13-verify (removed after verification)" + retention: "session-only; worktree removed post-verification" + items: + - id: "design-template-catalog-module" + kind: "other" + executor_role: "pr_verifier" + path: "skill_scripts/design_template_catalog.py" + sha256: "" + description: "New library module: DesignTemplate + DesignTemplateSelection dataclasses, 3 catalog entries, 4 public functions" + - id: "design-template-catalog-tests" + kind: "junit" + executor_role: "pr_verifier" + path: "tests/skill_scripts/test_design_template_catalog.py" + sha256: "" + description: "7 tests covering all AC6 behavioral dimensions; 7/7 PASSED" + - id: "full-suite-run" + kind: "summary" + executor_role: "pr_verifier" + path: "tests/skill_scripts/" + sha256: "" + description: "89/89 tests PASSED across full skill_scripts suite in isolated worktree" + - id: "manual-driver-output" + kind: "driver-output" + executor_role: "pr_verifier" + path: "local/manual-driver-session" + sha256: "" + description: "6/6 public API happy and refusal path checks PASSED via inline Python driver" + - id: "worker-evidence-ref" + kind: "other" + executor_role: "pr_verifier" + path: "docs/agents/evidence/issue-4-pr-13.yaml" + sha256: "" + description: "Pre-enforcement worker evidence; read as context only, not as acceptance proof" + +compact_summary: + outcome: "PR #13 passes all issue #4 ACs; library-only slice is correct, tested, and safe to merge." + automated_checks: "89/89 pytest PASSED (full suite); 7/7 design template tests PASSED; py_compile PASS on both new files." + manual_qa: "6/6 public API driver checks PASSED covering happy paths, refusal paths, and canonicalization." + risks_or_limitations: + - "LSP server unavailable in verifier session; mitigated by py_compile + runtime import + full pytest suite." + - "PR branch is 2 commits behind main (PR #14) but has zero file overlap; GitHub confirms MERGEABLE; no rebase required." + - "Browser/CLI integration is out of scope for issue #4 per issue spec; not a gap." + +next_recommended_step: "Main agent may merge PR #13, close issue #4, and select issue #5 for the next worker session." diff --git a/docs/agents/evidence/issue-5-pr-15.yaml b/docs/agents/evidence/issue-5-pr-15.yaml new file mode 100644 index 0000000..a5f6c04 --- /dev/null +++ b/docs/agents/evidence/issue-5-pr-15.yaml @@ -0,0 +1,121 @@ +schema_version: "1.0" +kind: evidence_packet +legacy_note: + pre_enforcement: true + limitation: "Fresh verifier separation was recorded, but this full-form evidence packet predates compact line-cap enforcement and is retained as historical detail." + +subject: + type: "issue_pr" + issue_number: "5" + pr_number: "15" + phase: "" + branch: "agent/issue-5-final-dashboard-approval-and-immutable-publish" + sha: "e466e373815404345d3134bf5cbe5dad8e7800d1" + +verifier: + actor: "pr_verifier (claude-sonnet-4.6 / Sisyphus-Junior)" + actor_role: "pr_verifier" + verifier_session_id: "pr15-verify-2026-05-07T06:08:05Z" + started_at: "2026-05-07T06:05:00Z" + completed_at: "2026-05-07T06:08:05Z" + +proof_of_separation: + worker_result_ref: "PR #15 body (worker self-checks declared as implementation feedback only)" + worker_actor: "worker session (Hephaestus / prior agent)" + worker_session_id: "pr15-worker-session" + verifier_actor: "pr_verifier (claude-sonnet-4.6 / Sisyphus-Junior)" + verifier_session_id: "pr15-verify-2026-05-07T06:08:05Z" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true + +status: "pass" + +acceptance_criteria_matrix: + - ac_id: "AC1" + status: "pass" + evidence_ref: "test_dashboard_draft_preview_includes_canvas_chart_specs_and_template + driver HAPPY_PATH" + note: "preview_dashboard_draft returns payload with canvas (layout+chart_specs), template, dashboard_id, state=draft; round-trip via load_dashboard_canvas confirmed." + - ac_id: "AC2" + status: "pass" + evidence_ref: "test_dashboard_draft_requires_final_analyst_approval_before_publish + driver REFUSAL_1" + note: "publish_dashboard on unapproved draft raises RuntimeError DASHBOARD_FINAL_APPROVAL_REQUIRED; approved draft publishes successfully." + - ac_id: "AC3" + status: "pass" + evidence_ref: "test_published_dashboard_version_is_immutable_after_draft_changes + driver IMMUTABILITY" + note: "PublishedDashboard is a frozen dataclass with deep-copied payload; mutating draft after publish leaves published.payload unchanged at version=1." + - ac_id: "AC4" + status: "pass" + evidence_ref: "test_data_meaning_changes_require_reapproval_before_republish + driver REFUSAL_2 + REPUBLISH_AFTER_REAPPROVAL" + note: "mark_dashboard_data_meaning_changed sets state=reapproval_required; publish_dashboard raises DASHBOARD_REAPPROVAL_REQUIRED; re-approval unblocks republish at version=2." + - ac_id: "AC5" + status: "pass" + evidence_ref: "test_approval_rejection_publish_and_reapproval_events_are_audited" + note: "Stable event names dashboard_final_approved, dashboard_final_rejected, dashboard_published, dashboard_reapproval_required recorded with dashboard_id and version identifiers." + - ac_id: "AC6" + status: "pass" + evidence_ref: "6/6 tests in test_dashboard_publish.py; test names cover draft, approved, published, rejected, reapproval_required, invalid transitions" + note: "All 6 tests exercise public functions only; no private helper (_required_text, _audit_events, etc.) called from test file." + +gates: + diagnostics_and_build_gate: + status: "pass" + evidence_ref: "py_compile COMPILE_OK; import IMPORT_OK; 30/30 regression tests pass in isolated worktree /tmp/opencode/wferp-pr15-verify" + surface_qa_gate: + status: "pass" + evidence_ref: "manual driver: HAPPY_PATH OK, REFUSAL_1 OK, REFUSAL_2 OK, REFUSAL_3 OK, REPUBLISH_AFTER_REAPPROVAL OK, IMMUTABILITY OK; ALL_DRIVER_CHECKS: PASS" + review_gate: + status: "pass" + evidence_ref: "code review inline: frozen dataclasses for immutability, deterministic RuntimeError codes, deep-copy via _copy_mapping, no private helpers in tests, no secrets/injection vectors" + security_gate: + status: "pass" + evidence_ref: "grep for eval/exec/subprocess/os.system/pickle/secrets: NO_INJECTION_VECTORS, NO_SECRETS_FOUND; library-only, no DB writes, no network calls" + +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true + +artifact_manifest: + bundle_ref: "docs/agents/evidence/issue-5-pr-15.yaml" + retention: "until issue #5 closed and PR #15 merged" + items: + - id: "compile-check" + kind: "log" + executor_role: "pr_verifier" + path: "inline: py_compile COMPILE_OK" + sha256: "" + description: "py_compile on dashboard_publish.py and test_dashboard_publish.py" + - id: "focused-publish-tests" + kind: "junit" + executor_role: "pr_verifier" + path: "inline: 6/6 passed test_dashboard_publish.py" + sha256: "" + description: "pytest focused publish workflow tests in isolated worktree" + - id: "regression-suite" + kind: "junit" + executor_role: "pr_verifier" + path: "inline: 30/30 passed chart_spec+canvas+design_template+publish" + sha256: "" + description: "Full dependency regression suite in isolated worktree" + - id: "manual-driver" + kind: "driver-output" + executor_role: "pr_verifier" + path: "inline: ALL_DRIVER_CHECKS: PASS" + sha256: "" + description: "Public API driver covering happy path, 3 refusal paths, republish, immutability" + - id: "security-scan" + kind: "log" + executor_role: "pr_verifier" + path: "inline: NO_INJECTION_VECTORS, NO_SECRETS_FOUND" + sha256: "" + description: "grep for injection vectors and secrets in changed files" + +compact_summary: + outcome: "PR #15 fully satisfies all 6 ACs for issue #5; all gates pass." + automated_checks: "py_compile OK; 6/6 focused publish tests pass; 30/30 dependency regression tests pass in isolated worktree." + manual_qa: "Public API driver confirmed happy path, 3 refusal paths (unapproved publish, reapproval-required republish, invalid transition), republish at version=2, and immutability of published payload." + risks_or_limitations: + - "Issue #6 token link/view-only runtime intentionally out of scope per issue packet." + - "Worker noted stale LSP import-resolution warnings in their session; verifier py_compile and import checks clean." + +next_recommended_step: "Merge PR #15 and close issue #5." diff --git a/docs/agents/evidence/issue-6-pr-18.yaml b/docs/agents/evidence/issue-6-pr-18.yaml new file mode 100644 index 0000000..d6c841f --- /dev/null +++ b/docs/agents/evidence/issue-6-pr-18.yaml @@ -0,0 +1,56 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces +subject: + type: "issue_pr" + issue_number: "6" + pr_number: "18" + phase: "" + branch: "agent/issue-6-published-dashboard-link-runtime" + sha: "8d753fa42f0509719364465d7ba3bf249ea293a2" +verifier: + actor: "Sisyphus-Junior" + actor_role: "pr_verifier" + verifier_session_id: "pr18-verify-2026-05-07T07:34:31Z" + started_at: "2026-05-07T07:27:00Z" + completed_at: "2026-05-07T07:34:31Z" +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-6.yaml" + worker_actor: "Sisyphus-Junior(issue_worker_repair)" + worker_session_id: "worker-result:issue-6@8d753fa" + verifier_actor: "Sisyphus-Junior(pr_verifier)" + verifier_session_id: "pr18-verify-2026-05-07T07:34:31Z" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true +status: "pass" +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "verifier-local:manual-library-driver", note: "Opaque 43-char token observed and remained scoped to dashboard-1/version 1."} + - {ac_id: "AC2", status: "pass", evidence_ref: "verifier-local:manual-library-driver", note: "Valid token rendered status=ok, view_only=true, empty authoring actions, and published payload."} + - {ac_id: "AC3", status: "pass", evidence_ref: "verifier-local:pytest-dashboard-publish+manual-library-driver", note: "Malformed, malformed-expiration, revoked, expired, and unpublished cases denied with dashboard/data null."} + - {ac_id: "AC4", status: "pass", evidence_ref: "verifier-local:manual-library-driver", note: "View and refresh both hit the read-only source; bounded 300s refresh returned fresh data."} + - {ac_id: "AC5", status: "pass", evidence_ref: "verifier-local:pytest-dashboard-publish+manual-library-driver", note: "59s/3601s refresh intervals raised policy errors and refresh failure preserved stale data with safe stale_error state."} + - {ac_id: "AC6", status: "pass", evidence_ref: "verifier-local:pytest-dashboard-publish+manual-library-driver", note: "Creation, malformed denial, revocation, expiration denial, view failure, and refresh failure audit events stayed stable."} +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier-local:lsp-py-compile-pytest"} + surface_qa_gate: {status: "pass", evidence_ref: "verifier-local:manual-library-driver"} + review_gate: {status: "pass", evidence_ref: "verifier-local:lightweight-review"} + security_gate: {status: "pass", evidence_ref: "verifier-local:focused-security-review"} +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true +artifact_manifest: + bundle_ref: "local-verifier:issue-6-pr-18@8d753fa" + retention: "Local verifier transcript only; repo stores compact refs without raw logs." + items: + - {id: "verifier-local:lsp-py-compile-pytest", kind: "summary", executor_role: "pr_verifier", path: "local verifier command summaries", sha256: "", description: "LSP diagnostics clean; py_compile passed; focused pytest passed 13/13."} + - {id: "verifier-local:manual-library-driver", kind: "summary", executor_role: "pr_verifier", path: "local inline Python driver", sha256: "", description: "Happy path, denial paths, bounded refresh, view failure, and refresh failure exercised through the public library surface."} + - {id: "verifier-local:lightweight-review", kind: "summary", executor_role: "pr_verifier", path: "local diff+AC review", sha256: "", description: "Goal/diff review found current HEAD satisfies AC1-AC6 without blocking quality concerns."} + - {id: "verifier-local:focused-security-review", kind: "summary", executor_role: "pr_verifier", path: "local code and surface review", sha256: "", description: "No secrets, write-SQL, network expansion, or denial-path data leak observed in the current diff."} +compact_summary: + outcome: "Fresh verifier run on PR #18 HEAD 8d753fa passes all issue #6 acceptance criteria and required gates." + automated_checks: "python3 -m py_compile changed files passed; python3 -m pytest tests/skill_scripts/test_dashboard_publish.py -q passed 13/13; lsp_diagnostics clean on changed Python files." + manual_qa: "Public library driver confirmed happy path, bounded refresh, malformed/revoked/expired/unpublished denials, safe initial view failure, and safe refresh stale_error behavior." + risks_or_limitations: ["Bearer-token links remain intentionally out of scope for authenticated RBAC/browser UI."] +next_recommended_step: "Human reviewer may review and merge PR #18 if no additional non-verifier concerns remain." diff --git a/docs/agents/evidence/issue-6.yaml b/docs/agents/evidence/issue-6.yaml new file mode 100644 index 0000000..236d310 --- /dev/null +++ b/docs/agents/evidence/issue-6.yaml @@ -0,0 +1,57 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces +subject: + type: "issue_pr" + issue_number: "6" + pr_number: "18" + phase: "" + branch: "agent/issue-6-published-dashboard-link-runtime" + sha: "35022e85e79b17583c768ff7ef37e12b04edd817" +verifier: + actor: "Sisyphus-Junior" + actor_role: "pr_verifier" + verifier_session_id: "local-verifier-issue-6" + started_at: "2026-05-07T06:55:48Z" + completed_at: "2026-05-07T06:55:48Z" +proof_of_separation: + worker_result_ref: "docs/agents/worker-results/issue-6.yaml" + worker_actor: "Sisyphus-Junior" + worker_session_id: "local-worker-issue-6" + verifier_actor: "Sisyphus-Junior" + verifier_session_id: "local-verifier-issue-6" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: true +status: "stale_after_worker_repair" +superseded_by: "docs/agents/evidence/issue-6-pr-18.yaml" +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "manual-library-driver:published-link-token", note: "Opaque token length/non-semantic value and dashboard/version scoping observed."} + - {ac_id: "AC2", status: "pass", evidence_ref: "manual-library-driver:view-only-render", note: "Valid token rendered ok with view_only true, no authoring actions, dashboard payload, and read-only data."} + - {ac_id: "AC3", status: "pass", evidence_ref: "manual-library-driver:denial-cases", note: "Malformed, unknown, revoked, expired, and unpublished tokens denied with no dashboard/data payload."} + - {ac_id: "AC4", status: "pass", evidence_ref: "manual-library-driver:read-only-refresh", note: "View and refresh read through read-only source; 60s bounded refresh accepted."} + - {ac_id: "AC5", status: "pass", evidence_ref: "manual-library-driver:refresh-policy-failure", note: "59s/3601s intervals rejected; refresh failure preserved stale data and emitted stale_error state."} + - {ac_id: "AC6", status: "pass", evidence_ref: "tests-and-driver:audit-events", note: "Creation, denial, revocation, expiration denial, and refresh failure audit events observed."} +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier-local:lsp-clean-py_compile-pytest"} + surface_qa_gate: {status: "pass", evidence_ref: "verifier-local:manual-library-driver"} + review_gate: {status: "pass", evidence_ref: "verifier-local:lightweight-goal-diff-review"} + security_gate: {status: "pass", evidence_ref: "verifier-local:focused-security-review"} +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true +artifact_manifest: + bundle_ref: "local-verifier-session:issue-6-no-pr" + retention: "local opencode transcript; repo packet keeps compact refs only; no PR existed during verification" + items: + - {id: "verifier-local:lsp-clean-py_compile-pytest", kind: "summary", executor_role: "pr_verifier", path: "local command summaries", sha256: "", description: "LSP diagnostics clean, py_compile passed, and focused pytest passed 11/11."} + - {id: "verifier-local:manual-library-driver", kind: "summary", executor_role: "pr_verifier", path: "inline python driver summary", sha256: "", description: "Library driver covered link creation, render, refresh, denial, stale/error, and audit observations."} + - {id: "verifier-local:lightweight-goal-diff-review", kind: "summary", executor_role: "pr_verifier", path: "local verifier review", sha256: "", description: "Focused review found implementation maps to all ACs without blocking quality concerns."} + - {id: "verifier-local:focused-security-review", kind: "summary", executor_role: "pr_verifier", path: "local verifier review", sha256: "", description: "No secrets, network/script install surface, write SQL, or dashboard data leak on denial observed."} +compact_summary: + outcome: "Prior issue #6 verifier acceptance passed before PR #18 traceability existed, but this packet is stale after post-review worker code changes." + automated_checks: "python3 -m py_compile changed files passed; python3 -m pytest tests/skill_scripts/test_dashboard_publish.py -q passed 11/11; LSP diagnostics clean." + manual_qa: "Manual library driver passed happy path, bounded refresh, malformed/unknown/revoked/expired/unpublished denials, and refresh failure stale/error state." + risks_or_limitations: ["Bearer-token links remain out-of-scope for authenticated RBAC; this evidence predates the worker repair for PR #18 review findings."] +next_recommended_step: "Use docs/agents/evidence/issue-6-pr-18.yaml for fresh PR #18 acceptance; keep this packet only as stale pre-repair history." diff --git a/docs/agents/evidence/issue-7-pr-12.yaml b/docs/agents/evidence/issue-7-pr-12.yaml new file mode 100644 index 0000000..d59272c --- /dev/null +++ b/docs/agents/evidence/issue-7-pr-12.yaml @@ -0,0 +1,101 @@ +schema_version: "1.0" +kind: evidence_packet + +subject: + type: "issue_pr" + issue_number: "7" + pr_number: "12" + phase: "dashboard-builder-mvp" + branch: "agent/issue-7-dashboard-canvas-layout-persistence" + sha: "d5e17d1" + +legacy_note: + pre_enforcement: true + not_proof_of_fresh_verifier_separation: true + reason: "Created before the P0 autonomous workflow role-separation packet requirements were enforced." + +verifier: + actor: "historical-verifier-summary" + started_at: "2026-05-07T00:00:00+08:00" + completed_at: "2026-05-07T00:00:00+08:00" + +status: "pass" + +acceptance_criteria_matrix: + - ac_id: "AC1" + status: "pass" + evidence_ref: "test_approved_chart_draft_can_be_added_to_dashboard_canvas" + note: "Approved chart draft placement succeeds and invalid raw/manual/unapproved drafts are refused." + - ac_id: "AC2" + status: "pass" + evidence_ref: "test_chart_can_be_moved_and_resized_without_losing_identity" + note: "Move and resize preserve chart identity through the public DashboardCanvas API." + - ac_id: "AC3" + status: "pass" + evidence_ref: "test_layout_persistence_round_trips_coordinates_dimensions_and_chart_payloads" + note: "Serialized layouts include chart identity, coordinates, dimensions, and chart payloads." + - ac_id: "AC4" + status: "pass" + evidence_ref: "test_layout_persistence_round_trips_coordinates_dimensions_and_chart_payloads" + note: "Persisted layouts reload and replay to matching rendered layout payloads." + - ac_id: "AC5" + status: "pass" + evidence_ref: "test_overlap_and_bounds_violations_are_rejected_deterministically" + note: "Overlap, bounds, fractional grid, and malformed chart metadata are rejected deterministically." + - ac_id: "AC6" + status: "pass" + evidence_ref: "command:pytest-dashboard-canvas" + note: "Focused dashboard canvas tests passed for add, move, resize, serialization, replay, and refusal paths." + +gates: + diagnostics_and_build_gate: + status: "pass" + evidence_ref: "command:py-compile-lsp-and-pytest" + surface_qa_gate: + status: "pass" + evidence_ref: "manual-library-driver:dashboard-canvas" + review_gate: + status: "pass" + evidence_ref: "oracle-review:focused-re-review-pass" + security_gate: + status: "pass" + evidence_ref: "diff-review:no-secrets-or-write-sql-surface" + +artifact_manifest: + bundle_ref: "local-session-evidence:commit-d5e17d1" + retention: "historical summary retained in repo; raw session output not duplicated in main agent context" + items: + - id: "command:py-compile-lsp-and-pytest" + kind: "summary" + path: "docs/agents/handoffs/issue-7.yaml" + sha256: "" + description: "Changed-file diagnostics, py_compile, focused tests, and full skill_scripts tests passed." + - id: "command:pytest-dashboard-canvas" + kind: "summary" + path: "docs/agents/handoffs/issue-7.yaml" + sha256: "" + description: "Focused dashboard canvas pytest run passed with 8 tests." + - id: "command:pytest-skill-scripts" + kind: "summary" + path: "docs/agents/handoffs/issue-7.yaml" + sha256: "" + description: "Relevant skill_scripts suite passed with 82 tests." + - id: "manual-library-driver:dashboard-canvas" + kind: "driver-output" + path: "docs/agents/handoffs/issue-7.yaml" + sha256: "" + description: "Library driver exercised placement, movement, resizing, persistence, replay, and invalid-input refusal paths." + - id: "oracle-review:focused-re-review-pass" + kind: "summary" + path: "docs/agents/handoffs/issue-7.yaml" + sha256: "" + description: "Focused Oracle re-review passed before PR #12 handoff." + +compact_summary: + outcome: "Issue #7 dashboard canvas layout persistence passed all recorded acceptance criteria." + automated_checks: "LSP diagnostics, py_compile, focused dashboard canvas tests, and relevant skill_scripts tests passed." + manual_qa: "Library driver exercised add, move, resize, serialize, reload, and invalid-input refusal paths." + risks_or_limitations: + - "Library-only tracer bullet; browser UI was out of scope." + +next_recommended_step: "After PR #12 human review/merge, continue with the next dependency-ready issue." diff --git a/docs/agents/evidence/phase-dashboard-builder-mvp.yaml b/docs/agents/evidence/phase-dashboard-builder-mvp.yaml new file mode 100644 index 0000000..4a6fc35 --- /dev/null +++ b/docs/agents/evidence/phase-dashboard-builder-mvp.yaml @@ -0,0 +1,45 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: {type: "phase", issue_number: "", pr_number: "", phase: "dashboard-builder-mvp", branch: "agent/issue-continuation-runner", sha: "b2a3c5d36965f8c026354eca65135dfce48067a2"} +verifier: {actor: "OpenCode", actor_role: "phase_verifier", verifier_session_id: "phase-verifier:dashboard-builder-mvp-ac17-2026-05-08", started_at: "2026-05-08T23:01:03+08:00", completed_at: "2026-05-08T23:01:03+08:00"} + +proof_of_separation: + worker_result_ref: "docs/agents/runtime/context-checkpoint.yaml + docs/agents/worker-results/issue-6.yaml" + worker_actor: "Hephaestus/main_orchestrator + prior issue workers" + worker_session_id: "checkpoint-only-resume" + verifier_actor: "OpenCode" + verifier_session_id: "phase-verifier:dashboard-builder-mvp-ac17-2026-05-08" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: false + +status: "pass" + +acceptance_criteria_matrix: + - {ac_id: "AC17", status: "pass", evidence_ref: "docs/agents/handoffs/issue-2.yaml + docs/agents/handoffs/issue-3.yaml + docs/agents/handoffs/issue-4.yaml + docs/agents/handoffs/issue-5.yaml + docs/agents/handoffs/issue-6.yaml + phase verifier real CLI/API run", note: "Fresh phase verification observed prompt -> validated SQL -> approved result -> chart creation -> grid canvas -> Design Template selection -> final approval -> publish -> token view -> bounded refresh -> revoke, while pre-approval chart creation and pre-approval publish remained blocked."} + +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "pytest tests/skill_scripts/{test_governed_query,test_chart_spec,test_dashboard_canvas,test_design_template_catalog,test_dashboard_publish}.py -q (45 passed) + py_compile selected skill_scripts modules + docker start/seed wferp-mssql-test"} + surface_qa_gate: {status: "pass", evidence_ref: "python3 -m skill_scripts.cli_generate_select --governed-query-json against test DB + fresh phase verifier public API driver"} + review_gate: {status: "pass", evidence_ref: "phase verifier scope review against PRD #1 AC17 and completed slice handoffs"} + security_gate: {status: "pass", evidence_ref: "read-only test DB execution only; no repo secrets added; revoked token denial observed"} + +role_boundary: {acceptance_qa_owner: "phase_verifier", main_agent_ran_issue_qa: false, worker_self_checks_are_not_final_acceptance: true} + +artifact_manifest: + bundle_ref: "local phase verifier run 2026-05-08" + retention: "Repo docs keep compact refs only; raw command output stays outside repo docs." + items: + - {id: "phase-cli-governed-query", kind: "summary", executor_role: "phase_verifier", path: "python3 -m skill_scripts.cli_generate_select", sha256: "", description: "Real test-DB governed query CLI returned ACTMK 2026 budget evidence with required columns and aggregate check."} + - {id: "phase-public-api-driver", kind: "summary", executor_role: "phase_verifier", path: "python3 inline phase driver", sha256: "", description: "Fresh verifier chained approval gating, chart drafts, canvas placement, template selection, publish, token view, bounded refresh, and revoked-link denial."} + - {id: "phase-regression-suite", kind: "summary", executor_role: "phase_verifier", path: "tests/skill_scripts/test_governed_query.py + test_chart_spec.py + test_dashboard_canvas.py + test_design_template_catalog.py + test_dashboard_publish.py", sha256: "", description: "Cross-issue MVP regression suite passed 45/45."} + +compact_summary: + outcome: "AC17 passes for the current dashboard-builder-mvp MVP surfaces." + automated_checks: "Cross-issue regression tests passed 45/45, selected modules compiled, the SQL Server test container was started/seeded, and the governed-query CLI executed against the real test DB." + manual_qa: "Fresh phase verification completed the full prompt-to-published-dashboard journey and confirmed both chart-before-approval and publish-before-final-approval gates." + risks_or_limitations: ["Current MVP verification surface is CLI/library-based rather than a browser authoring UI; this packet does not claim a separate web UI E2E flow."] + +next_recommended_step: "Main orchestrator should record AC17 as satisfied, then proceed to phase_refactor or documentation_and_tracker_update." diff --git a/docs/agents/evidence/pr-22.yaml b/docs/agents/evidence/pr-22.yaml new file mode 100644 index 0000000..4287bd7 --- /dev/null +++ b/docs/agents/evidence/pr-22.yaml @@ -0,0 +1,48 @@ +schema_version: "1.0" +kind: evidence_packet +line_cap: 60 +raw_evidence_policy: index_only_manifest_no_raw_logs_or_traces + +subject: {type: "issue_pr", issue_number: "", pr_number: "22", phase: "", branch: "agent/bypass-approval-policy", sha: "213d2991ee3d8553675914e67cf2142c1180e7eb"} +verifier: {actor: "Sisyphus-Junior", actor_role: "pr_verifier", verifier_session_id: "pr-verifier:pr-22-bypass-approval-policy-2026-05-07", started_at: "2026-05-07T20:15:00+08:00", completed_at: "2026-05-07T20:15:00+08:00"} + +proof_of_separation: + worker_result_ref: "no-linked-local-issue-packet-found" + worker_actor: "n/a" + worker_session_id: "n/a" + verifier_actor: "Sisyphus-Junior" + verifier_session_id: "pr-verifier:pr-22-bypass-approval-policy-2026-05-07" + verifier_is_distinct_from_worker: true + verifier_read_worker_result_only: false + +status: "pass" + +acceptance_criteria_matrix: + - {ac_id: "AC1", status: "pass", evidence_ref: "python3 scripts/agent_context_budget_check.py + python3 -m py_compile scripts/agent_context_budget_check.py", note: "Both required verifier commands passed from repo root."} + - {ac_id: "AC2", status: "pass", evidence_ref: "docs/agents/evidence-packet-template.yaml + docs/agents/evidence/issue-16-pr-17.yaml + docs/agents/evidence/issue-20-pr-21.yaml", note: "Compact structure matches repo template/examples; PR #22 and commit 213d2991ee3d8553675914e67cf2142c1180e7eb are recorded; issue_number is intentionally empty."} + +gates: + diagnostics_and_build_gate: {status: "pass", evidence_ref: "verifier summary: context_budget_check pass; py_compile pass"} + surface_qa_gate: {status: "not_applicable", evidence_ref: "policy packet only; no browser/UI surface"} + review_gate: {status: "pass", evidence_ref: "template/example parity review"} + security_gate: {status: "pass", evidence_ref: "doc-only; no secrets, credentials, or network surface"} + +role_boundary: + acceptance_qa_owner: "pr_verifier" + main_agent_ran_issue_qa: false + worker_self_checks_are_not_final_acceptance: true + +artifact_manifest: + bundle_ref: "local verifier run for PR #22" + retention: "repo docs keep compact refs only; raw logs stay out of repo docs." + items: + - {id: "verifier-script-gate", kind: "summary", executor_role: "pr_verifier", path: "scripts/agent_context_budget_check.py", sha256: "", description: "Required context-budget and py_compile checks passed."} + - {id: "verifier-packet-review", kind: "summary", executor_role: "pr_verifier", path: "docs/agents/evidence/pr-22.yaml", sha256: "", description: "Packet records PR #22, commit 213d2991ee3d8553675914e67cf2142c1180e7eb, and the no-linked-local-issue-packet limitation."} + +compact_summary: + outcome: "PR #22 is documented for release_worker merge-eligibility review." + automated_checks: "python3 scripts/agent_context_budget_check.py passed; python3 -m py_compile scripts/agent_context_budget_check.py passed; no raw logs recorded." + manual_qa: "Template/example parity checked; no linked local issue packet was found." + risks_or_limitations: ["No linked local issue packet was found, so issue_number is intentionally empty and this packet is PR-scoped only.", "Verifier evidence is compact command-summary only; raw logs remain outside repo docs."] + +next_recommended_step: "Have release_worker evaluate PR #22 against this packet." diff --git a/docs/agents/handoffs/issue-16.yaml b/docs/agents/handoffs/issue-16.yaml new file mode 100644 index 0000000..fa33ca5 --- /dev/null +++ b/docs/agents/handoffs/issue-16.yaml @@ -0,0 +1,35 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 + +issue_identity: + number: "16" + title: "Add context budget gate and release worker workflow" + url: "https://github.com/paulpai0412/wferp/issues/16" + branch: "agent/issue-16-context-budget-release-worker" + +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_checked, evidence_ref: "scripts/agent_context_budget_check.py", note: "Workflow and checkpoint template define 45/50 gates and checkpoint-only resume."} + - {ac_id: "AC2", status: worker_self_checked, evidence_ref: "docs/agents/autonomous-development-workflow.yaml#release_worker_policy", note: "Release worker is gated by verifier pass, mergeability, checks, and human approval."} + - {ac_id: "AC3", status: worker_self_checked, evidence_ref: "scripts/agent_context_budget_check.py", note: "Artifact caps and index-only raw evidence policy are documented and script-checked."} + - {ac_id: "AC4", status: worker_self_checked, evidence_ref: "scripts/agent_context_budget_check.py", note: "Script validates caps and checkpoint fields without reading raw logs."} + - {ac_id: "AC5", status: worker_self_checked, evidence_ref: "docs/agents/autonomous-development-workflow.yaml#priority_bridge", note: "Main-agent contract validation only and verifier-owned QA remain explicit."} + +compact_verification_summary: + status: worker_self_checked + note: "Worker self-check only; final acceptance requires a fresh verifier packet." + command: "python3 scripts/agent_context_budget_check.py" + +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-16-pr-.yaml" + worker_artifact_bundle: "local worker self-check summary only" + raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context." + +files_changed_summary: + - {path: "docs/agents/autonomous-development-workflow.yaml", summary: "Added context budget, artifact cap, and release worker policies."} + - {path: "docs/agents/runtime/context-checkpoint.yaml", summary: "Added checkpoint-only resume template."} + - {path: "scripts/agent_context_budget_check.py", summary: "Added compact policy/cap script gate."} + - {path: "docs/agents/*-template.yaml and issue-tracker.md", summary: "Added cap and release/index-only policy references."} + +blockers_or_risks: ["No worker blocker; verifier packet still required for final acceptance."] +next_recommended_step: "Delegate a fresh verifier to run issue #16 acceptance checks." diff --git a/docs/agents/handoffs/issue-2.yaml b/docs/agents/handoffs/issue-2.yaml new file mode 100644 index 0000000..26d7c84 --- /dev/null +++ b/docs/agents/handoffs/issue-2.yaml @@ -0,0 +1,24 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +legacy_note: {historical_reconstruction: true, pre_enforcement: true, limitation: "Compact repo-local handoff reconstructed after PR #8 merged; not a fresh verifier handoff."} +issue_identity: {number: "2", title: "Governed query evidence path", url: "https://github.com/paulpai0412/wferp/issues/2", branch: "agent/issue-2-governed-query-evidence-path", pr: "https://github.com/paulpai0412/wferp/pull/8"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC1", note: "PR #8 documented one governed SQL statement or a clear refusal/error state."} + - {ac_id: "AC2", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC2", note: "Historical PR evidence and merged tests capture non-SELECT, metadata-invalid, and SQL2000-incompatible refusals."} + - {ac_id: "AC3", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC3", note: "Read-only execution through the test boundary was documented in PR #8 and remains visible in merged governed-query code."} + - {ac_id: "AC4", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC4", note: "Evidence payload fields are reconstructable from PR #8 summaries and merged code."} + - {ac_id: "AC5", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC5", note: "Chart-spec creation remains blocked until explicit query-result approval."} + - {ac_id: "AC6", status: pass, evidence_ref: "docs/agents/evidence/issue-2-pr-8.yaml#AC6", note: "Focused tests for fake DB/LLM paths and execution-result validation were recorded in PR #8."} +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "historical pass: PR #8 body recorded pytest, CLI QA, and changed-file LSP summaries." + surface_qa_gate: "historical pass: PR #8 body recorded happy-path governed JSON output and refusal-path denial." + review_gate: "historical pass: merged PR #8 closed issue #2 before role-separation enforcement." +evidence_packet_refs: {verifier_packet: "docs/agents/evidence/issue-2-pr-8.yaml", pr: "https://github.com/paulpai0412/wferp/pull/8", raw_evidence_policy: "Detailed logs remain outside repo docs; this handoff keeps compact refs only."} +files_changed_summary: + - {path: "skill_scripts/governed_query.py", summary: "Added governed query evidence payload, approval states, and chart-spec gating."} + - {path: "skill_scripts/cli_generate_select.py", summary: "Exposed the governed-query JSON CLI surface used in PR #8 evidence."} + - {path: "tests/skill_scripts/test_governed_query.py", summary: "Covered happy path, refusal path, execution evidence, and approval gating."} +blockers_or_risks: ["Historical worker/verifier artifacts were not captured pre-enforcement; see legacy note in the evidence packet."] +next_recommended_step: "Reference this handoff and docs/agents/evidence/issue-2-pr-8.yaml when auditing PRD #1 coverage." diff --git a/docs/agents/handoffs/issue-20.yaml b/docs/agents/handoffs/issue-20.yaml new file mode 100644 index 0000000..e270358 --- /dev/null +++ b/docs/agents/handoffs/issue-20.yaml @@ -0,0 +1,22 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "20", title: "Reconstruct governed query traceability artifacts", url: "https://github.com/paulpai0412/wferp/issues/20", branch: "agent/issue-20-reconstruct-governed-query-traceability"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: pass, evidence_ref: "docs/agents/evidence/issue-20-pr-21.yaml#AC1", note: "Fresh verifier confirmed the issue #20 packet records retrospective PR #8 traceability repair within cap."} + - {ac_id: "AC2", status: pass, evidence_ref: "docs/agents/evidence/issue-20-pr-21.yaml#AC2", note: "Fresh verifier confirmed the issue #2 handoff maps all historical ACs to compact durable refs."} + - {ac_id: "AC3", status: pass, evidence_ref: "docs/agents/evidence/issue-20-pr-21.yaml#AC3", note: "Fresh verifier confirmed the issue #2 evidence packet carries gates, proof-of-separation fields, and explicit historical limits."} + - {ac_id: "AC4", status: pass, evidence_ref: "docs/agents/evidence/issue-20-pr-21.yaml#AC4", note: "Fresh verifier confirmed the checkpoint can route PRD #1 completeness work back to the reconstructed issue #2 refs."} + - {ac_id: "AC5", status: pass, evidence_ref: "docs/agents/evidence/issue-20-pr-21.yaml#AC5", note: "Fresh verifier confirmed the reconstructed artifacts stay compact and index-only without raw logs or transcripts."} +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: verifier ran context-budget validation, py_compile, and changed-file diagnostics for the docs/script slice." + surface_qa_gate: "pass: verifier confirmed compact retrospective refs, explicit historical limits, and no inlined raw logs." + review_gate: "pass: lightweight PR #21 diff review matched the workflow self-repair scope; security gate also passed." +evidence_packet_refs: {verifier_packet: "docs/agents/evidence/issue-20-pr-21.yaml", worker_artifact_bundle: "docs/agents/worker-results/issue-20.yaml", pr: "https://github.com/paulpai0412/wferp/pull/21", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "docs/agents/issue-packets/issue-20.yaml", summary: "Prepared the workflow self-repair issue packet."} + - {path: "docs/agents/evidence/issue-2-pr-8.yaml + docs/agents/handoffs/issue-2.yaml", summary: "Reconstructed compact issue #2 traceability from GitHub and merged-repo sources."} + - {path: "docs/agents/runtime/context-checkpoint.yaml + docs/agents/worker-results/issue-20.yaml", summary: "Updated workflow continuation and worker-result artifacts for the PRD audit handoff."} +blockers_or_risks: ["Issue #2 evidence remains historical-only traceability and cannot stand in for fresh product re-verification."] +next_recommended_step: "Resume the PRD #1 completeness audit using docs/agents/handoffs/issue-2.yaml and docs/agents/evidence/issue-2-pr-8.yaml." diff --git a/docs/agents/handoffs/issue-28.yaml b/docs/agents/handoffs/issue-28.yaml new file mode 100644 index 0000000..4539c23 --- /dev/null +++ b/docs/agents/handoffs/issue-28.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "28", title: "E2E: Validate dashboard builder MVP journey", url: "https://github.com/paulpai0412/wferp/issues/28", branch: "agent/issue-28-ac17-e2e-test-case", pr: "https://github.com/paulpai0412/wferp/pull/29"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Worker target case covers prompt -> validated SQL -> approved result -> chart -> canvas -> template -> final approval -> publish -> token view -> bounded refresh -> revoke."} + - {ac_id: "AC2", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Worker target case refuses chart creation before query approval and publish before final dashboard approval."} + - {ac_id: "AC3", status: worker_self_check_pass, evidence_ref: "docs/agents/worker-results/issue-28.yaml", note: "Worker result records E2E-001 test_case_repair and keeps final_acceptance_claim=false with no raw logs."} + - {ac_id: "AC4", status: verifier_pass, evidence_ref: "docs/agents/evidence/issue-28-pr-29.yaml", note: "Fresh verifier packet records proof of separation plus passing target-case and regression-bucket acceptance evidence for PR #29."} +compact_verification_summary: + status: verifier_acceptance_passed + diagnostics_and_build_gate: "pass: verifier-owned focused pytest suite passed 46/46 for the target case plus required regression bucket." + surface_qa_gate: "pass: fresh verifier reran the CLI/library target case and confirmed refusal gates before charting and publishing." + review_gate: "pass: PR #29 diff review found no goal, security, or role-boundary violations." +evidence_packet_refs: {worker_result: "docs/agents/worker-results/issue-28.yaml", verifier_packet: "docs/agents/evidence/issue-28-pr-29.yaml", pr: "https://github.com/paulpai0412/wferp/pull/29", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "tests/skill_scripts/test_dashboard_builder_e2e.py", summary: "Adds the focused executable E2E-001 target case on the CLI/library MVP surface."} + - {path: "docs/agents/e2e/test-case-catalog.yaml + docs/agents/runtime/context-checkpoint.yaml", summary: "Updates compact workflow metadata for PR #29 and the pending verifier handoff."} + - {path: "docs/agents/worker-results/issue-28.yaml + docs/agents/handoffs/issue-28.yaml", summary: "Stores the compact worker result and orchestrator/verifier handoff for issue #28."} +blockers_or_risks: ["CLI/library acceptance passed; browser UI coverage is out of scope for this issue and remains unclaimed."] +next_recommended_step: "Hand PR #29 to release_worker or human merge review; verifier acceptance is complete and should not be rerun unless the PR changes." diff --git a/docs/agents/handoffs/issue-3.yaml b/docs/agents/handoffs/issue-3.yaml new file mode 100644 index 0000000..bbaa45a --- /dev/null +++ b/docs/agents/handoffs/issue-3.yaml @@ -0,0 +1,64 @@ +schema_version: "1.0" +kind: issue_handoff + +legacy_note: + pre_enforcement: true + not_proof_of_fresh_verifier_separation: true + reason: "Created before the P0 autonomous workflow role-separation packet requirements were enforced." + +issue_identity: + number: "3" + title: "Chart Spec authoring and validation" + url: "https://github.com/paulpai0412/wferp/issues/3" + branch: "agent/issue-3-chart-spec-authoring-and-validation" + +acceptance_criteria_evidence: + - ac_id: "AC1" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC1" + note: "Approved query result gating is covered for accepted and rejected chart spec authoring." + - ac_id: "AC2" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC2" + note: "Table, KPI card, bar, and line chart payloads are covered through the public authoring surface." + - ac_id: "AC3" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC3" + note: "Validation covers chart type, mappings, sort, title, color, number format, and date format." + - ac_id: "AC4" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC4" + note: "Invalid mapping, unsupported field/type, invalid sort/color/format, and public revalidation paths are rejected." + - ac_id: "AC5" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC5" + note: "Preview rendering returns table rows, KPI value/label, and ordered bar/line data." + - ac_id: "AC6" + status: pass + evidence_ref: "docs/agents/evidence/issue-3-pr-11.yaml#AC6" + note: "Focused chart spec behavior tests pass for valid and invalid MVP specs." + +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: changed Python files had no LSP diagnostics and py_compile passed." + surface_qa_gate: "pass: library driver exercised chart authoring, rendering, revalidation, and refusal paths." + review_gate: "pass: issue scope verified before PR #11 was merged." + +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-3-pr-11.yaml" + worker_artifact_bundle: "local session evidence from commit 104a171" + pr: "https://github.com/paulpai0412/wferp/pull/11" + raw_evidence_policy: "Detailed command output stays in verifier artifacts; this handoff keeps compact refs only." + +files_changed_summary: + - path: "skill_scripts/chart_spec.py" + summary: "Added chart spec authoring, validation, and preview rendering surface." + - path: "tests/skill_scripts/test_chart_spec.py" + summary: "Covered accepted chart specs, invalid specs, rendering, and refusal paths." + - path: "docs/agents/handoffs/issue-3.yaml" + summary: "Recorded compact issue handoff evidence." + +blockers_or_risks: + - "No blocker. This is library-only MVP surface; no dashboard UI or persistence was added by scope." + +next_recommended_step: "Issue #3 is complete; use this compact handoff pattern for future issue evidence." diff --git a/docs/agents/handoffs/issue-31.yaml b/docs/agents/handoffs/issue-31.yaml new file mode 100644 index 0000000..1fc29f3 --- /dev/null +++ b/docs/agents/handoffs/issue-31.yaml @@ -0,0 +1,22 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "31", title: "E2E: Deterministic ACTMK fixture and SQL correctness", url: "https://github.com/paulpai0412/wferp/issues/31", branch: "agent/issue-31-deterministic-actmk-sql-correctness", pr: "https://github.com/paulpai0412/wferp/pull/35"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_check_pass, evidence_ref: "test_db/init/01_create_wferp_test.sql + tests/skill_scripts/test_sql_correctness_e2e.py", note: "Worker confirmed the deterministic ACTMK fixture contains three 2026 rows totaling 450000 plus one 2025 contrast row."} + - {ac_id: "AC2", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_sql_correctness_e2e.py", note: "Worker reran the real DB_ENV=test governed-query path and observed MK002/MK006, row_count=3, and sum(MK006)=450000."} + - {ac_id: "AC3", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_sql_correctness_e2e.py", note: "Worker asserted the 2025 contrast row exists in the fixture but remains excluded from the 2026 result."} + - {ac_id: "AC4", status: worker_self_check_pass, evidence_ref: "skill_scripts/database_client.py + tests/skill_scripts/test_database_client.py", note: "Worker preserved DB_ENV=test and read-only SQL guardrails while keeping result-based assertions primary."} + - {ac_id: "AC5", status: worker_self_check_pass, evidence_ref: "docs/agents/worker-results/issue-31.yaml + docs/agents/handoffs/issue-31.yaml", note: "Worker artifacts remain compact and index-only with no raw SQL logs or transcripts."} +compact_verification_summary: + status: verifier_acceptance_pass + diagnostics_and_build_gate: "pass: fresh verifier reran DB-backed focused pytest bucket, py_compile, and targeted LSP checks." + surface_qa_gate: "pass: fresh verifier confirmed CLI governed-query DB_ENV=test happy path and DB_ENV_NOT_TEST guardrail." + review_gate: "pass: fresh verifier reviewed PR #35 diff/status; PR was MERGEABLE/CLEAN with no configured CI checks." +evidence_packet_refs: {worker_result: "docs/agents/worker-results/issue-31.yaml", verifier_packet: "docs/agents/evidence/issue-31-pr-35.yaml", pr: "https://github.com/paulpai0412/wferp/pull/35", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "test_db/init/01_create_wferp_test.sql + tests/skill_scripts/test_sql_correctness_e2e.py", summary: "Adds the deterministic ACTMK 2026/2025 fixture shape and the focused result-based E2E-002 target case."} + - {path: "skill_scripts/database_client.py + tests/skill_scripts/test_database_client.py", summary: "Preserves read-only test DB access and covers the connection parsing used by the governed-query execution path."} + - {path: "docs/agents/worker-results/issue-31.yaml + docs/agents/handoffs/issue-31.yaml", summary: "Stores the compact worker result and updates the handoff for fresh verifier pickup."} +blockers_or_risks: ["Issue #31 released via PR #35; downstream E2E issues #32/#33 may now be considered serially."] +next_recommended_step: "Continue the autonomous workflow with the next unblocked ready-for-agent E2E issue." diff --git a/docs/agents/handoffs/issue-32.yaml b/docs/agents/handoffs/issue-32.yaml new file mode 100644 index 0000000..48afd61 --- /dev/null +++ b/docs/agents/handoffs/issue-32.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "32", title: "E2E: Prompt-to-Published-Dashboard live LLM evidence", url: "https://github.com/paulpai0412/wferp/issues/32", branch: "agent/issue-32-prompt-to-published-dashboard-e2e", pr: "https://github.com/paulpai0412/wferp/pull/36"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_check_pass, evidence_ref: "skill_scripts/governed_query.py + tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Worker records live LLM governed-query evidence separately and classifies provider-unavailable as non-blocking llm_availability."} + - {ac_id: "AC2", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Deterministic target covers approved query result to MK006 bar Chart Spec and Dashboard Canvas placement."} + - {ac_id: "AC3", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Target preserves final Analyst Approval before publish and view-only Published Dashboard Link render."} + - {ac_id: "AC4", status: worker_self_check_pass, evidence_ref: "focused dashboard regression bucket", note: "Required deterministic bucket passed independently from live LLM availability."} + - {ac_id: "AC5", status: worker_self_check_pass, evidence_ref: "docs/agents/worker-results/issue-32.yaml + docs/agents/handoffs/issue-32.yaml", note: "Worker artifacts are compact/index-only and final acceptance remains verifier-owned."} +compact_verification_summary: + status: waiting_for_fresh_verifier + diagnostics_and_build_gate: "worker self-check pass: target case 2/2, deterministic bucket 45/45, py_compile passed; final gate belongs to verifier." + surface_qa_gate: "pending: fresh verifier required after worker PR." + review_gate: "pending: fresh verifier/review required after worker PR." +evidence_packet_refs: {worker_result: "docs/agents/worker-results/issue-32.yaml", verifier_packet: "docs/agents/evidence/issue-32-pr-36.yaml", pr: "https://github.com/paulpai0412/wferp/pull/36", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "skill_scripts/governed_query.py + tests/skill_scripts/test_dashboard_builder_e2e.py", summary: "Adds compact non-blocking live LLM evidence classification beside the deterministic E2E-001 target case."} + - {path: "docs/agents/e2e/test-case-catalog.yaml + docs/agents/worker-results/issue-32.yaml + docs/agents/handoffs/issue-32.yaml", summary: "Records compact worker metadata for fresh verifier pickup while preserving issue #28 historical E2E-001 refs."} +blockers_or_risks: ["Fresh verifier evidence is required before final acceptance.", "No browser UI exists; issue #32 remains CLI/library only.", "Live LLM evidence is non-blocking by default; deterministic regression is the release gate."] +next_recommended_step: "Spawn a fresh verifier for PR #36 / issue #32 / E2E-001." diff --git a/docs/agents/handoffs/issue-33.yaml b/docs/agents/handoffs/issue-33.yaml new file mode 100644 index 0000000..011997d --- /dev/null +++ b/docs/agents/handoffs/issue-33.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "33", title: "E2E: Query Analyst Approval gate before Chart Spec", url: "https://github.com/paulpai0412/wferp/issues/33", branch: "agent/issue-33-e2e-query-analyst-approval-gate-before-chart-spe", pr: "https://github.com/paulpai0412/wferp/pull/37"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_query_approval_chart_spec_e2e.py", note: "Worker observed a governed query result for 查詢2026年的工程預算明細 with MK002/MK006 columns and three deterministic 2026 rows."} + - {ac_id: "AC2", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_query_approval_chart_spec_e2e.py", note: "Worker confirmed both author_chart_spec and create_dashboard_chart_draft refuse the unapproved governed query result with QUERY_RESULT_NOT_APPROVED."} + - {ac_id: "AC3", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_query_approval_chart_spec_e2e.py", note: "Worker approved the same query result and observed bar Chart Spec creation succeed with MK006 as the value field."} + - {ac_id: "AC4", status: worker_self_check_pass, evidence_ref: "docs/agents/worker-results/issue-33.yaml", note: "Worker artifacts stay compact and index-only; refusal and success path evidence is summarized without raw logs."} + - {ac_id: "AC5", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_governed_query.py + tests/skill_scripts/test_chart_spec.py + tests/skill_scripts/test_dashboard_canvas.py", note: "Focused regression bucket passed without weakening governed-query, Chart Spec, or Dashboard Canvas assertions."} +compact_verification_summary: + status: verifier_pending + diagnostics_and_build_gate: "worker self-check pass: target case 2/2, focused regression bucket 25/25, py_compile, and changed-file LSP checks passed on HEAD 0200368." + surface_qa_gate: "pending fresh verifier: issue #33 is a library surface and worker does not claim final acceptance." + review_gate: "pending fresh verifier review for PR #37." +evidence_packet_refs: {worker_result: "docs/agents/worker-results/issue-33.yaml", verifier_packet: "docs/agents/evidence/issue-33-pr-37.yaml", pr: "https://github.com/paulpai0412/wferp/pull/37", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "tests/skill_scripts/test_query_approval_chart_spec_e2e.py", summary: "Adds the focused E2E-003 target case for refusal-before-approval and success-after-approval behavior."} + - {path: "docs/agents/e2e/test-case-catalog.yaml + docs/agents/worker-results/issue-33.yaml + docs/agents/handoffs/issue-33.yaml", summary: "Records compact orchestrator/worker metadata for verifier pickup and PR #37 traceability."} +blockers_or_risks: ["Fresh verifier evidence for PR #37 is still required.", "Issue #33 covers only the current library surface; no browser UI exists yet."] +next_recommended_step: "Spawn a fresh pr_verifier for PR #37 to confirm E2E-003 plus the focused dashboard-builder regression bucket." diff --git a/docs/agents/handoffs/issue-34.yaml b/docs/agents/handoffs/issue-34.yaml new file mode 100644 index 0000000..afbe31c --- /dev/null +++ b/docs/agents/handoffs/issue-34.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: {number: "34", title: "E2E: Revoked Published Dashboard Link denial", url: "https://github.com/paulpai0412/wferp/issues/34", branch: "agent/issue-34-revoked-published-dashboard-link-denial", pr: "https://github.com/paulpai0412/wferp/pull/38"} +acceptance_criteria_evidence: + - {ac_id: "AC1", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py", note: "Existing target flow renders a valid Published Dashboard Link in view-only mode before revocation."} + - {ac_id: "AC2", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_builder_e2e.py + tests/skill_scripts/test_dashboard_publish.py", note: "Revoked-token access returns denied with LINK_TOKEN_REVOKED through the library surface."} + - {ac_id: "AC3", status: worker_self_check_pass, evidence_ref: "tests/skill_scripts/test_dashboard_publish.py", note: "Denied revoked-link paths keep dashboard and data null, so no payload rows leak."} + - {ac_id: "AC4", status: worker_self_check_pass, evidence_ref: "focused dashboard publish regression bucket", note: "Compact audit evidence stays index-only in worker artifacts; no raw logs were added to repo docs."} + - {ac_id: "AC5", status: worker_self_check_pass, evidence_ref: "docs/agents/worker-results/issue-34.yaml + docs/agents/handoffs/issue-34.yaml", note: "Worker artifacts stay compact/index-only and leave final acceptance to a fresh verifier."} +compact_verification_summary: + status: waiting_for_fresh_verifier + diagnostics_and_build_gate: "worker self-check pass: focused dashboard-publish bucket passed 15/15; changed YAML artifacts are diagnostics-clean; final gate belongs to verifier." + surface_qa_gate: "pending: fresh verifier required for independent library-surface confirmation." + review_gate: "pending: fresh verifier/review required after worker PR." +evidence_packet_refs: {worker_result: "docs/agents/worker-results/issue-34.yaml", verifier_packet: "", pr: "https://github.com/paulpai0412/wferp/pull/38", raw_evidence_policy: "Index-only refs in repo docs; raw logs stay outside main-agent context."} +files_changed_summary: + - {path: "docs/agents/e2e/test-case-catalog.yaml", summary: "Absorbs the user-authorized deletion into issue #34 without touching the other unrelated dirty files in the workspace."} + - {path: "docs/agents/worker-results/issue-34.yaml + docs/agents/handoffs/issue-34.yaml", summary: "Records compact worker and handoff metadata for fresh verifier pickup."} +blockers_or_risks: ["Fresh verifier evidence is still required before final acceptance.", "No browser UI exists; issue #34 remains library-surface only."] +next_recommended_step: "Spawn a fresh verifier for issue #34 after the worker PR is opened." diff --git a/docs/agents/handoffs/issue-4.yaml b/docs/agents/handoffs/issue-4.yaml new file mode 100644 index 0000000..e1601ed --- /dev/null +++ b/docs/agents/handoffs/issue-4.yaml @@ -0,0 +1,65 @@ +schema_version: "1.0" +kind: issue_handoff +legacy_note: + pre_enforcement: true + limitation: "Fresh verifier separation was recorded, but this full-form handoff predates compact line-cap enforcement and is retained as historical detail." +issue_identity: + number: "4" + title: "Design Template catalog and selection" + url: "https://github.com/paulpai0412/wferp/issues/4" + branch: "agent/issue-4-design-template-catalog-and-selection" + +acceptance_criteria_evidence: + - ac_id: "AC1" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC1" + note: "Curated generic Design Template options are exposed through a deterministic library payload." + - ac_id: "AC2" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC2" + note: "Official option names use generic descriptive names rather than copied source brand names." + - ac_id: "AC3" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC3" + note: "The analyst-facing public API selects exactly one allowed Design Template." + - ac_id: "AC4" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC4" + note: "The selected template is applied to a Dashboard Canvas preview payload." + - ac_id: "AC5" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC5" + note: "Switching templates replaces the active selection and does not retain the previous template." + - ac_id: "AC6" + status: pass + evidence_ref: "docs/agents/evidence/issue-4-pr-13.yaml#AC6" + note: "Focused tests cover allowed templates, generic naming, single-template selection, canonicalization, replacement, and deterministic presentation payloads." + +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: fresh post-enforcement verifier recorded py_compile, runtime import, focused tests, and full skill_scripts pytest evidence; LSP was unavailable in verifier session." + surface_qa_gate: "pass: fresh post-enforcement verifier recorded public API driver coverage for template listing, selection, canvas preview application, switching, refusal, and canonicalization." + review_gate: "pass: fresh post-enforcement verifier recorded diff, context, and security review evidence and confirmed PR #13 is mergeable after PR #14." + +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-4-pr-13.yaml" + verifier_session_id: "opencode-session-pr13-verify-2026-05-07" + worker_artifact_bundle: "pre-enforcement local session evidence; retained as context only" + pr: "https://github.com/paulpai0412/wferp/pull/13" + raw_evidence_policy: "Raw verifier logs stay outside main-agent context; this handoff keeps compact refs only." + +files_changed_summary: + - path: "skill_scripts/design_template_catalog.py" + summary: "Added curated Design Template catalog, deterministic presentation payloads, selection, replacement, and Dashboard Canvas preview application." + - path: "tests/skill_scripts/test_design_template_catalog.py" + summary: "Covered generic naming, allowed/refused selection, canonical selection application, one-template-at-a-time replacement, deterministic payloads, and canvas preview application." + - path: "docs/agents/evidence/issue-4-pr-13.yaml" + summary: "Recorded compact evidence for issue #4 acceptance criteria and gates." + - path: "docs/agents/handoffs/issue-4.yaml" + summary: "Recorded issue-scoped handoff evidence for downstream issue #5." + +blockers_or_risks: + - "No product blocker. This is a library-only tracer bullet; browser UI/CLI integration is out of scope." + - "LSP server was unavailable in the fresh verifier session; py_compile, runtime import, focused tests, public API driver, and full pytest suite evidence passed." + +next_recommended_step: "Merge PR #13, close issue #4, then continue issue #5 final Dashboard Approval and immutable publish." diff --git a/docs/agents/handoffs/issue-5.yaml b/docs/agents/handoffs/issue-5.yaml new file mode 100644 index 0000000..fe9f7fe --- /dev/null +++ b/docs/agents/handoffs/issue-5.yaml @@ -0,0 +1,67 @@ +schema_version: "1.0" +kind: issue_handoff +legacy_note: + pre_enforcement: true + limitation: "Fresh verifier separation was recorded, but this full-form handoff predates compact line-cap enforcement and is retained as historical detail." +issue_identity: + number: "5" + title: "Final Dashboard Approval and immutable publish" + url: "https://github.com/paulpai0412/wferp/issues/5" + branch: "agent/issue-5-final-dashboard-approval-and-immutable-publish" + +acceptance_criteria_evidence: + - ac_id: "AC1" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC1" + note: "Dashboard draft preview includes persisted canvas layout, chart specs, template, dashboard id, and draft state." + - ac_id: "AC2" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC2" + note: "Publishing an unapproved draft is refused with a deterministic final-approval-required error." + - ac_id: "AC3" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC3" + note: "Publishing creates a frozen, deep-copied PublishedDashboard version distinct from later draft changes." + - ac_id: "AC4" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC4" + note: "SQL, data-source, or query-semantics changes force re-approval before republishing and unblock after fresh approval." + - ac_id: "AC5" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC5" + note: "Approval, rejection, publish, and re-approval-required events are recorded with stable identifiers." + - ac_id: "AC6" + status: pass + evidence_ref: "docs/agents/evidence/issue-5-pr-15.yaml#AC6" + note: "Focused tests cover draft, approved, published, rejected, reapproval-required, and invalid transition paths through public APIs." + +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: verifier recorded py_compile/import success and 30/30 dependency regression tests in an isolated worktree." + surface_qa_gate: "pass: verifier public API driver covered happy path, refusal paths, republish after re-approval, and immutability." + review_gate: "pass: verifier recorded code/security/context review evidence; no DB write, network, or issue #6 runtime scope was added." + +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-5-pr-15.yaml" + verifier_session_id: "pr15-verify-2026-05-07T06:08:05Z" + worker_artifact_bundle: "worker self-checks from PR #15 body; implementation feedback only" + pr: "https://github.com/paulpai0412/wferp/pull/15" + raw_evidence_policy: "Raw verifier logs stay outside main-agent context; this handoff keeps compact refs only." + +files_changed_summary: + - path: "docs/agents/issue-packets/issue-5.yaml" + summary: "Prepared the issue #5 autonomous worker packet." + - path: "skill_scripts/dashboard_publish.py" + summary: "Added library-only dashboard draft approval, rejection, immutable publish, re-approval requirement, and audit event surface." + - path: "tests/skill_scripts/test_dashboard_publish.py" + summary: "Covered draft preview, approval-required publish refusal, immutable published versions, re-approval-required republish, rejection/invalid transitions, and audit events." + - path: "docs/agents/evidence/issue-5-pr-15.yaml" + summary: "Recorded verifier-owned evidence packet for issue #5." + - path: "docs/agents/handoffs/issue-5.yaml" + summary: "Recorded compact issue handoff evidence for downstream issue #6." + +blockers_or_risks: + - "No issue #5 blocker identified. Published Dashboard Link/view-only runtime remains intentionally out of scope for issue #6." + - "Worker reported stale LSP import-resolution warnings, but verifier py_compile/import and regression tests passed." + +next_recommended_step: "After PR #15 merge, continue issue #6 Published Dashboard Link and view-only runtime." diff --git a/docs/agents/handoffs/issue-6.yaml b/docs/agents/handoffs/issue-6.yaml new file mode 100644 index 0000000..e06cc6c --- /dev/null +++ b/docs/agents/handoffs/issue-6.yaml @@ -0,0 +1,35 @@ +schema_version: "1.0" +kind: issue_handoff +line_cap: 35 +issue_identity: + number: "6" + title: "Published Dashboard Link and view-only runtime" + url: "https://github.com/paulpai0412/wferp/issues/6" + branch: "agent/issue-6-published-dashboard-link-runtime" + pr: "https://github.com/paulpai0412/wferp/pull/18" +acceptance_criteria_evidence: + - {ac_id: "AC1", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC1", note: "Fresh verifier observed opaque token scoped to dashboard-1/version 1."} + - {ac_id: "AC2", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC2", note: "Fresh verifier rendered a valid link as view-only with no authoring controls."} + - {ac_id: "AC3", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC3", note: "Fresh verifier observed safe denial for malformed, malformed-expiration, revoked, expired, and unpublished cases."} + - {ac_id: "AC4", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC4", note: "Fresh verifier observed read-only view/refresh calls and bounded refresh success."} + - {ac_id: "AC5", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC5", note: "Fresh verifier observed too-fast/too-slow interval refusal plus safe stale_error refresh handling."} + - {ac_id: "AC6", status: pass, evidence_ref: "docs/agents/evidence/issue-6-pr-18.yaml#AC6", note: "Fresh verifier confirmed stable audit events for create, deny, revoke, expire, and refresh/view failures."} +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: py_compile, focused pytest (13/13), and lsp_diagnostics were clean on the current PR head." + surface_qa_gate: "pass: public library driver covered happy path, denial/error path, bounded refresh, and stale_error handling on HEAD 8d753fa." + review_gate: "pass: lightweight verifier review and focused security review found no blocking merge concerns." +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-6-pr-18.yaml" + verifier_session_id: "pr18-verify-2026-05-07T07:34:31Z" + worker_result: "docs/agents/worker-results/issue-6.yaml" + superseded_prior_packet: "docs/agents/evidence/issue-6.yaml" + raw_evidence_policy: "Raw verifier logs stay outside repo docs; only compact refs are kept here." +files_changed_summary: + - {path: "skill_scripts/dashboard_publish.py", summary: "Published-link runtime adds safe denial, view failure, bounded refresh, and audit behavior."} + - {path: "tests/skill_scripts/test_dashboard_publish.py", summary: "Focused public-interface tests cover all issue #6 link/runtime acceptance paths."} + - {path: "docs/agents/evidence/issue-6-pr-18.yaml", summary: "Fresh verifier-owned acceptance evidence for PR #18 on HEAD 8d753fa."} + - {path: "docs/agents/evidence/issue-6.yaml", summary: "Prior stale verifier packet retained only as superseded history."} +blockers_or_risks: + - "Bearer-token links remain intentionally out of scope for authenticated RBAC and browser UI." +next_recommended_step: "Human reviewer may use docs/agents/evidence/issue-6-pr-18.yaml for merge review of PR #18." diff --git a/docs/agents/handoffs/issue-7.yaml b/docs/agents/handoffs/issue-7.yaml new file mode 100644 index 0000000..68b136a --- /dev/null +++ b/docs/agents/handoffs/issue-7.yaml @@ -0,0 +1,64 @@ +schema_version: "1.0" +kind: issue_handoff + +legacy_note: + pre_enforcement: true + not_proof_of_fresh_verifier_separation: true + reason: "Created before the P0 autonomous workflow role-separation packet requirements were enforced." + +issue_identity: + number: "7" + title: "Dashboard Canvas layout persistence" + url: "https://github.com/paulpai0412/wferp/issues/7" + branch: "agent/issue-7-dashboard-canvas-layout-persistence" + +acceptance_criteria_evidence: + - ac_id: "AC1" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC1" + note: "Approved chart drafts can be added; raw, manual, or unapproved drafts are refused before placement." + - ac_id: "AC2" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC2" + note: "Move and resize preserve chart identity through the public DashboardCanvas API." + - ac_id: "AC3" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC3" + note: "Serialized payloads include chart_id, coordinates, dimensions, and chart payloads." + - ac_id: "AC4" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC4" + note: "Persisted layouts reload and replay to the same rendered layout payload." + - ac_id: "AC5" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC5" + note: "Overlap, bounds, fractional grid, and malformed persisted chart metadata are rejected deterministically." + - ac_id: "AC6" + status: pass + evidence_ref: "docs/agents/evidence/issue-7-pr-12.yaml#AC6" + note: "Focused dashboard canvas tests cover add, move, resize, serialization, replay, and refusal paths." + +compact_verification_summary: + status: pass + diagnostics_and_build_gate: "pass: changed Python files had no LSP diagnostics, py_compile passed, and focused/full skill_scripts tests passed." + surface_qa_gate: "pass: library driver exercised add, move, resize, serialize, reload, and invalid-input refusal paths." + review_gate: "pass: focused Oracle re-review passed before PR #12 handoff." + +evidence_packet_refs: + verifier_packet: "docs/agents/evidence/issue-7-pr-12.yaml" + worker_artifact_bundle: "local session evidence from commit d5e17d1" + pr: "https://github.com/paulpai0412/wferp/pull/12" + raw_evidence_policy: "Detailed command output stays in verifier artifacts; this handoff keeps compact refs only." + +files_changed_summary: + - path: "skill_scripts/dashboard_canvas.py" + summary: "Added dashboard canvas chart placement, movement, resizing, persistence, and replay validation." + - path: "tests/skill_scripts/test_dashboard_canvas.py" + summary: "Covered canvas placement, identity, persistence round trip, and deterministic rejection cases." + - path: "docs/agents/handoffs/issue-7.yaml" + summary: "Recorded compact issue handoff evidence." + +blockers_or_risks: + - "No blocker identified. This issue remains a library-only Dashboard Canvas tracer bullet; no browser UI exists in scope." + +next_recommended_step: "After PR #12 human review/merge, continue with the next dependency-ready issue." diff --git a/docs/agents/issue-packet-template.yaml b/docs/agents/issue-packet-template.yaml new file mode 100644 index 0000000..9aa185f --- /dev/null +++ b/docs/agents/issue-packet-template.yaml @@ -0,0 +1,74 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +issue: + number: "" + title: "" + url: "" + labels: [ready-for-agent] + parent: {type: "", reference: ""} + +branch: {name: "agent/issue--", base: "main"} +objective: "" + +acceptance_criteria: + - {id: "AC1", text: "", evidence_hint: ""} + +test_case: + applies: false + id: "" + source: {type: "", ref: ""} + surface: "" + scenario: "" + expected_outcome: "" + regression_bucket: [""] + +scope: + in: [""] + out: [""] + +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml"] + relevant_paths: [""] + prior_handoff: "" + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + +implementation_notes: + constraints: + - "Keep artifacts within line caps and raw evidence index-only." + risks: [""] + dependencies: [""] + +verification_plan: + worker_self_checks: + - {command: "", purpose: "implementation feedback only; not final acceptance QA", success_criteria: ""} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "", success_criteria: ""} + verifier_manual_qa: + owner: "pr_verifier" + surface: "" + happy_path: "" + refusal_or_error_path: "" + +handoff_contract: + repo_path: "docs/agents/handoffs/issue-.yaml" + github_summary_required: true + required_sections: [issue_identity, acceptance_criteria_evidence, compact_verification_summary, evidence_packet_refs, files_changed_summary, blockers_or_risks, next_recommended_step] + +result_contract: + template_path: "docs/agents/worker-result-template.yaml" + verifier_template_path: "docs/agents/evidence-packet-template.yaml" + allowed_statuses: [success, blocked, failed] + required_fields: [issue, branch, status, summary, files_changed, verification, pr, blockers, next_recommended_step, evidence_packet_refs, role_boundary] + forbidden_payloads: [full_worker_transcript, full_verifier_transcript, raw_test_logs, verbose_manual_qa_transcript] + +role_boundary: + orchestrator_may_validate_contract_only: true + worker_may_emit_final_acceptance: false + verifier_packet_required_for_completion: true + +prepared_by: {actor: "", prepared_at: ""} diff --git a/docs/agents/issue-packets/issue-16.yaml b/docs/agents/issue-packets/issue-16.yaml new file mode 100644 index 0000000..bc7d61e --- /dev/null +++ b/docs/agents/issue-packets/issue-16.yaml @@ -0,0 +1,68 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 + +issue: + number: "16" + title: "Add context budget gate and release worker workflow" + url: "https://github.com/paulpai0412/wferp/issues/16" + labels: [ready-for-agent] + parent: {type: "workflow-self-repair", reference: "docs/agents/autonomous-development-workflow.yaml"} + +branch: {name: "agent/issue-16-context-budget-release-worker", base: "main"} + +objective: "Add enforceable context-budget controls and a narrow release_worker role to the autonomous workflow contract." + +acceptance_criteria: + - {id: "AC1", text: "Workflow defines 45% checkpoint warning, 50% stop/rotate, and checkpoint-only cross-session resume.", evidence_hint: "Verifier inspects workflow policy and checkpoint template."} + - {id: "AC2", text: "Workflow defines release_worker conditional merge/close rules after verifier pass, mergeability, required checks, and human approval policy.", evidence_hint: "Verifier inspects release worker policy and tracker docs."} + - {id: "AC3", text: "Issue/evidence/handoff/worker artifacts have explicit line caps and index-only raw-evidence policy.", evidence_hint: "Script gate validates caps on templates and current issue packet."} + - {id: "AC4", text: "A repo script validates artifact caps and required checkpoint fields without reading raw logs.", evidence_hint: "Verifier runs script and records compact output."} + - {id: "AC5", text: "Docs preserve main-agent contract-validation-only role and verifier-owned QA.", evidence_hint: "Verifier checks no workflow text authorizes main QA."} + +scope: + in: + - "docs/agents/autonomous-development-workflow.yaml" + - "docs/agents/*-template.yaml, docs/agents/issue-tracker.md" + - "docs/agents/runtime/context-checkpoint.yaml" + - "scripts/agent_context_budget_check.py" + out: + - "Product issue #6 implementation" + - "Direct main-agent QA, production release automation, destructive git operations" + +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml", "docs/agents/issue-tracker.md"] + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + relevant_paths: ["docs/agents", "scripts"] + prior_handoff: "none" + +implementation_notes: + constraints: + - "line_cap: issue_packet<=80, handoff<=35, evidence_packet<=60, checkpoint<=80, worker_result<=80; raw evidence is index-only." + - "Keep issue packet <=80 lines, handoff <=35, evidence <=60, checkpoint <=80." + - "release_worker may merge/close only when verifier packet passes and human approval policy is satisfied; otherwise blocked." + - "Use GIT_MASTER=1 prefix for git commands." + risks: ["Do not weaken role separation by letting main run QA."] + dependencies: ["User selected conditional direct release_worker execution."] + +verification_plan: + worker_self_checks: + - {command: "python3 scripts/agent_context_budget_check.py", purpose: "implementation feedback only", success_criteria: "Artifact caps and checkpoint schema pass."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "python3 scripts/agent_context_budget_check.py", success_criteria: "Verifier observes pass."} + verifier_manual_qa: + owner: "pr_verifier" + surface: "docs-script" + happy_path: "Inspect workflow docs and run the context budget script." + refusal_or_error_path: "Temporarily reason about over-cap artifact or missing checkpoint field and confirm script/policy would block." + +handoff_contract: {repo_path: "docs/agents/handoffs/issue-16.yaml", github_summary_required: true} +result_contract: + template_path: "docs/agents/worker-result-template.yaml" + verifier_template_path: "docs/agents/evidence-packet-template.yaml" + allowed_statuses: [success, blocked, failed] + forbidden_payloads: [full_worker_transcript, full_verifier_transcript, raw_test_logs] +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-07T14:30:39+08:00"} diff --git a/docs/agents/issue-packets/issue-20.yaml b/docs/agents/issue-packets/issue-20.yaml new file mode 100644 index 0000000..484a0af --- /dev/null +++ b/docs/agents/issue-packets/issue-20.yaml @@ -0,0 +1,57 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 + +issue: + number: "20" + title: "Reconstruct governed query traceability artifacts" + url: "https://github.com/paulpai0412/wferp/issues/20" + labels: [ready-for-agent] + parent: {type: "prd", reference: "https://github.com/paulpai0412/wferp/issues/1"} + +branch: {name: "agent/issue-20-reconstruct-governed-query-traceability", base: "main"} +objective: "Rebuild compact autonomous-workflow artifacts for issue #2 / PR #8 so PRD #1 coverage can be audited without missing traceability." + +acceptance_criteria: + - {id: "AC1", text: "Add a compact issue packet for retrospective traceability repair of issue #2 that points to merged PR #8 and stays within artifact caps.", evidence_hint: "Verifier reads docs/agents/issue-packets/issue-20.yaml and confirms compact scope and refs."} + - {id: "AC2", text: "Add a compact handoff for issue #2 that maps acceptance criteria to durable evidence refs without raw logs.", evidence_hint: "Verifier reads docs/agents/handoffs/issue-2.yaml and checks AC mapping + compact refs."} + - {id: "AC3", text: "Add a compact evidence packet for issue #2 / PR #8 with status, AC coverage, gates, artifact refs, and proof-of-separation fields, while calling out historical limitations.", evidence_hint: "Verifier reads docs/agents/evidence/issue-2-pr-8.yaml and confirms schema + historical-note language."} + - {id: "AC4", text: "Update runtime checkpoint and/or phase audit summary so PRD #1 coverage review can reference issue #2 artifacts.", evidence_hint: "Verifier reads docs/agents/runtime/context-checkpoint.yaml and any new audit summary for issue #20 continuation state."} + - {id: "AC5", text: "Keep raw logs, traces, and transcripts outside repo docs; store only compact refs.", evidence_hint: "Verifier checks artifact text for index-only refs and absence of raw logs."} + +scope: + in: ["docs/agents/issue-packets/issue-20.yaml", "docs/agents/handoffs/issue-2.yaml", "docs/agents/evidence/issue-2-pr-8.yaml", "docs/agents/runtime/context-checkpoint.yaml", "compact audit summary if needed"] + out: ["Re-implementing governed query product code", "Running final product QA as main agent", "Pasting raw test logs or full transcripts into repo docs"] + +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml", "docs/prds/2026-05-06-workflow-erp-dashboard-builder.md", "docs/agents/runtime/context-checkpoint.yaml", "docs/agents/evidence/issue-3-pr-11.yaml", "docs/agents/evidence/issue-5-pr-15.yaml", "docs/agents/evidence/issue-6-pr-18.yaml"] + relevant_paths: ["docs/agents", "docs/prds", "skill_scripts/governed_query.py", "tests/skill_scripts/test_governed_query.py"] + prior_handoff: "docs/agents/handoffs/issue-6.yaml" + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + +implementation_notes: + constraints: + - "This is workflow self-repair only; rebuild traceability artifacts from existing issue #2 / PR #8 evidence sources." + - "Do not claim fresh product verification that did not happen; clearly label retrospective or historical evidence where needed." + - "Keep artifact payloads compact and index-only; no raw logs, screenshots, or full transcripts." + - "Use GIT_MASTER=1 prefix for git commands." + risks: ["Historical PR #8 verification lived in GitHub PR text rather than repo-local evidence packet, so retrospective packets must be explicit about limitations."] + dependencies: ["Closed: issue #2 Governed query evidence path merged via PR #8."] + +verification_plan: + worker_self_checks: + - {command: "python3 scripts/agent_context_budget_check.py", purpose: "implementation feedback only", success_criteria: "Updated workflow artifacts stay within declared caps and required fields remain present."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "python3 scripts/agent_context_budget_check.py", success_criteria: "Verifier observes pass after issue #20 artifacts are written."} + verifier_manual_qa: + owner: "pr_verifier" + surface: "docs-script" + happy_path: "Read issue #20 packet plus issue #2 handoff/evidence and confirm PRD audit can map AC1-AC5 to issue #2 refs." + refusal_or_error_path: "Confirm no raw logs were inlined and any historical limitations are explicitly disclosed rather than hidden." + +handoff_contract: {repo_path: "docs/agents/handoffs/issue-20.yaml", github_summary_required: true} +result_contract: {template_path: "docs/agents/worker-result-template.yaml", verifier_template_path: "docs/agents/evidence-packet-template.yaml", allowed_statuses: [success, blocked, failed]} +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-07T18:00:00+08:00"} diff --git a/docs/agents/issue-packets/issue-28.yaml b/docs/agents/issue-packets/issue-28.yaml new file mode 100644 index 0000000..76deb20 --- /dev/null +++ b/docs/agents/issue-packets/issue-28.yaml @@ -0,0 +1,62 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +issue: + number: "28" + title: "E2E: Validate dashboard builder MVP journey" + url: "https://github.com/paulpai0412/wferp/issues/28" + labels: [ready-for-agent, e2e-test-case] + parent: {type: "prd", reference: "https://github.com/paulpai0412/wferp/issues/1#AC17"} + +branch: {name: "agent/issue-28-ac17-e2e-test-case", base: "main"} +objective: "Materialize PRD #1 AC17 as one executable E2E target case for the Dashboard Builder MVP journey." + +acceptance_criteria: + - {id: "AC1", text: "The repo contains or reuses one focused executable AC17 E2E target case that covers prompt to validated SQL, approved result, chart creation, canvas layout, template selection, final approval, token publication/viewing, bounded refresh, and revocation.", evidence_hint: "Verifier reruns the target case and records target_case_result."} + - {id: "AC2", text: "The target case checks at least one validation/refusal gate before charting or publishing, not only the happy path.", evidence_hint: "Verifier confirms pre-approval charting or pre-final-approval publishing is blocked."} + - {id: "AC3", text: "Worker result records test_case_repair for E2E-001 without claiming final acceptance or inlining raw logs.", evidence_hint: "Verifier reads docs/agents/worker-results/issue-28.yaml."} + - {id: "AC4", text: "Fresh verifier evidence records target case and regression bucket status with proof of separation.", evidence_hint: "Verifier writes docs/agents/evidence/issue-28-pr-.yaml."} + +test_case: + applies: true + id: "E2E-001" + source: {type: "prd_ac", ref: "#1 AC17"} + surface: "cli" + scenario: "Prompt-to-published-dashboard MVP journey through existing CLI/library surfaces." + expected_outcome: "Journey completes without bypassing validation gates; revoked token access is denied." + regression_bucket: ["test_governed_query", "test_chart_spec", "test_dashboard_canvas", "test_design_template_catalog", "test_dashboard_publish"] + +scope: + in: ["tests/skill_scripts", "skill_scripts", "docs/agents/e2e/test-case-catalog.yaml", "docs/agents/worker-results/issue-28.yaml"] + out: ["Browser UI implementation", "Changing AC17 expected behavior", "Pasting raw logs or traces into repo docs"] + +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml", "docs/agents/e2e/test-case-issue-loop.md", "docs/prds/2026-05-06-workflow-erp-dashboard-builder.md"] + relevant_paths: ["skill_scripts/governed_query.py", "skill_scripts/chart_spec.py", "skill_scripts/dashboard_canvas.py", "skill_scripts/design_template_catalog.py", "skill_scripts/dashboard_publish.py", "tests/skill_scripts"] + prior_handoff: "docs/agents/evidence/phase-dashboard-builder-mvp.yaml" + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + +implementation_notes: + constraints: + - "Issue worker may run target checks for implementation feedback only; final acceptance belongs to a fresh verifier." + - "Do not weaken assertions or change expected outcomes without opening/linking a spec repair issue." + - "Keep raw evidence index-only; no full logs, screenshots, SQL execution logs, or transcripts in repo docs/comments." + - "Use GIT_MASTER=1 prefix for git commands." + risks: ["Current MVP surface is CLI/library-based, not a browser authoring UI."] + dependencies: ["PRD #1 slice coverage passed; AC17 now requires E2E test case issue verification."] + +verification_plan: + worker_self_checks: + - {command: "python3 -m pytest tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q", purpose: "implementation feedback only", success_criteria: "Target case and focused regression bucket pass before worker returns."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "rerun target E2E-001 case and regression bucket", success_criteria: "Verifier observes target_case=pass and regression_bucket=pass."} + verifier_manual_qa: {owner: "pr_verifier", surface: "cli", happy_path: "Run AC17 target journey", refusal_or_error_path: "Confirm a pre-approval gate blocks invalid order."} + +handoff_contract: {repo_path: "docs/agents/handoffs/issue-28.yaml", github_summary_required: true} +result_contract: {template_path: "docs/agents/worker-result-template.yaml", verifier_template_path: "docs/agents/evidence-packet-template.yaml", allowed_statuses: [success, blocked, failed]} +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-09T15:25:00+08:00"} diff --git a/docs/agents/issue-packets/issue-31.yaml b/docs/agents/issue-packets/issue-31.yaml new file mode 100644 index 0000000..1a8d61c --- /dev/null +++ b/docs/agents/issue-packets/issue-31.yaml @@ -0,0 +1,63 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +issue: + number: "31" + title: "E2E: Deterministic ACTMK fixture and SQL correctness" + url: "https://github.com/paulpai0412/wferp/issues/31" + labels: [ready-for-agent, e2e-test-case] + parent: {type: "prd", reference: "https://github.com/paulpai0412/wferp/issues/30"} + +branch: {name: "agent/issue-31-deterministic-actmk-sql-correctness", base: "main"} +objective: "Materialize E2E-002 by proving deterministic 2026 ACTMK budget correctness through the existing governed-query test-db path." + +acceptance_criteria: + - {id: "AC1", text: "Deterministic ACTMK fixture data provides three 2026 rows plus one 2025 contrast row, with the 2026 rows summing MK006 to 450000.", evidence_hint: "Verifier checks test_db/init/01_create_wferp_test.sql and rerun output refs."} + - {id: "AC2", text: "A real DB_ENV=test execution path verifies returned columns include MK002 and MK006, the 2026 result row count is 3, and sum(MK006)=450000.", evidence_hint: "Verifier reruns tests/skill_scripts/test_sql_correctness_e2e.py against the seeded test DB."} + - {id: "AC3", text: "The 2025 contrast row remains in fixture coverage but is excluded from the 2026 prompt-intent result.", evidence_hint: "Verifier confirms the contrast-row assertion and the 2026 query result assertions both pass."} + - {id: "AC4", text: "The slice preserves existing non-production execution guardrails and does not treat exact SQL string equality as the primary assertion.", evidence_hint: "Verifier confirms DB_ENV=test guardrails and result-based assertions in governed-query/sql correctness tests."} + - {id: "AC5", text: "Worker and verifier artifacts stay compact and reference manifests instead of embedding raw SQL logs.", evidence_hint: "Verifier checks issue #31 worker-result/evidence packet shape and index-only wording."} + +test_case: + applies: true + id: "E2E-002" + source: {type: "prd_ac", ref: "#30 E2E-002"} + surface: "sql" + scenario: "Deterministic 2026 engineering-budget SQL correctness through governed-query execution in DB_ENV=test." + expected_outcome: "The 2026 prompt returns exactly three MK002/MK006 rows totaling 450000, while the seeded 2025 contrast row remains excluded from the result." + regression_bucket: ["test_sql_correctness_e2e", "test_governed_query", "test_execution_validator", "test_sql_router"] + +scope: + in: ["test_db/init/01_create_wferp_test.sql", "tests/skill_scripts/test_sql_correctness_e2e.py", "tests/skill_scripts/test_governed_query.py", "tests/skill_scripts/test_sql_router.py", "tests/skill_scripts/test_database_client.py", "tests/skill_scripts/test_prompt_sql_consistency.py", "skill_scripts/cli_generate_select.py", "skill_scripts/database_client.py", "skill_scripts/governed_query.py", "skill_scripts/sql_router.py", "skill_scripts/execution_validator.py"] + out: ["Browser UI coverage", "Changing prompt intent away from 2026 engineering budget", "Exact SQL string equality as the release assertion", "Pasting raw SQL logs into repo docs"] + +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml", "docs/agents/e2e/test-case-issue-loop.md", "docs/prds/2026-05-09-sql-to-dashboard-e2e-test-cases.md"] + relevant_paths: ["test_db/init/01_create_wferp_test.sql", "test_db/README.md", "tests/skill_scripts/test_sql_correctness_e2e.py", "tests/skill_scripts/test_governed_query.py", "tests/skill_scripts/test_sql_router.py", "tests/skill_scripts/test_database_client.py", "tests/skill_scripts/test_prompt_sql_consistency.py", "skill_scripts/cli_generate_select.py", "skill_scripts/governed_query.py", "skill_scripts/database_client.py", "skill_scripts/sql_router.py", "skill_scripts/execution_validator.py"] + prior_handoff: "docs/agents/handoffs/issue-28.yaml" + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + +implementation_notes: + constraints: + - "Issue worker may run deterministic DB checks for implementation feedback only; final acceptance belongs to a fresh verifier." + - "Use only DB_ENV=test execution paths and preserve existing read-only guardrails." + - "Judge correctness by result evidence (columns, row count, aggregates), not exact SQL string equality." + - "Keep worker/verifier artifacts compact and index-only; no raw SQL logs or transcripts." + risks: ["SQL Server 2019 test containers need explicit legacy guardrails because true SQL Server 2000 compatibility mode is unavailable."] + dependencies: ["Issue #28 is released; issue #31 is the next serial E2E issue and unblocks issues #32 and #33."] + +verification_plan: + worker_self_checks: + - {command: "WFERP_RUN_DB_E2E=1 python3 -m pytest tests/skill_scripts/test_sql_correctness_e2e.py tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_execution_validator.py tests/skill_scripts/test_sql_router.py -q", purpose: "implementation feedback only", success_criteria: "Deterministic ACTMK target case and focused regression bucket pass in DB_ENV=test before the worker returns."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "rerun the deterministic ACTMK SQL correctness target case in DB_ENV=test", success_criteria: "Verifier observes three 2026 rows, required columns MK002/MK006, and sum(MK006)=450000 while the 2025 contrast row remains excluded."} + verifier_manual_qa: {owner: "pr_verifier", surface: "sql", happy_path: "Observe governed-query evidence for the 2026 prompt through the seeded ACTMK fixture.", refusal_or_error_path: "Confirm the 2025 contrast row exists in fixture coverage but is absent from the 2026 result set."} + +handoff_contract: {repo_path: "docs/agents/handoffs/issue-31.yaml", github_summary_required: true} +result_contract: {template_path: "docs/agents/worker-result-template.yaml", verifier_template_path: "docs/agents/evidence-packet-template.yaml", allowed_statuses: [success, blocked, failed]} +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-09T20:04:48+08:00"} diff --git a/docs/agents/issue-packets/issue-32.yaml b/docs/agents/issue-packets/issue-32.yaml new file mode 100644 index 0000000..9cf23b2 --- /dev/null +++ b/docs/agents/issue-packets/issue-32.yaml @@ -0,0 +1,53 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: + number: "32" + title: "E2E: Prompt-to-Published-Dashboard live LLM evidence" + url: "https://github.com/paulpai0412/wferp/issues/32" + labels: [ready-for-agent, e2e-test-case] + parent: {type: "prd", reference: "https://github.com/paulpai0412/wferp/issues/30"} +branch: {name: "agent/issue-32-prompt-to-published-dashboard-e2e", base: "main"} +objective: "Materialize the CLI/library prompt-to-published-dashboard E2E path while recording live LLM governed-query evidence as non-blocking evidence and deterministic regression as the release gate." +acceptance_criteria: + - {id: "AC1", text: "The target case records live LLM governed-query evidence for `查詢2026年的工程預算明細` and classifies live-LLM failures separately from product/test-data failures.", evidence_hint: "Verifier checks compact artifact refs and failure classification; live LLM availability is non-blocking by default."} + - {id: "AC2", text: "The approved query result can create a bar Chart Spec using MK006 as the budget value field and place it on a Dashboard Canvas.", evidence_hint: "Verifier reruns deterministic library/CLI target and chart/canvas regression bucket."} + - {id: "AC3", text: "Final Analyst Approval is required before publishing, and the resulting Published Dashboard Link renders in view-only mode.", evidence_hint: "Verifier observes final approval gate, publish, link creation, and render through the existing library surface."} + - {id: "AC4", text: "The deterministic regression bucket covers governed query, Chart Spec, Dashboard Canvas, Design Template, and Published Dashboard runtime behavior without depending on live LLM availability.", evidence_hint: "Verifier reruns focused tests for the dashboard builder path in deterministic mode."} + - {id: "AC5", text: "Worker/verifier artifacts follow the E2E issue loop with worker self-checks as implementation feedback only and final acceptance in a fresh verifier packet.", evidence_hint: "Contract validation checks worker result and verifier evidence packet shape."} +test_case: + applies: true + id: "E2E-001" + source: {type: "prd_ac", ref: "#30 E2E-001"} + surface: "cli|library" + scenario: "Prompt-to-Published-Dashboard happy path for 2026 engineering budget." + expected_outcome: "Live LLM evidence is recorded separately; deterministic flow approves the query, creates an MK006 bar chart, places it on a canvas, approves/publishes, and renders a view-only link." + regression_bucket: ["test_governed_query", "test_chart_spec", "test_dashboard_canvas", "test_design_template_catalog", "test_dashboard_publish"] +scope: + in: ["skill_scripts/governed_query.py", "skill_scripts/chart_spec.py", "skill_scripts/dashboard_canvas.py", "skill_scripts/design_template_catalog.py", "skill_scripts/dashboard_publish.py", "tests/skill_scripts/test_governed_query.py", "tests/skill_scripts/test_chart_spec.py", "tests/skill_scripts/test_dashboard_canvas.py", "tests/skill_scripts/test_design_template_catalog.py", "tests/skill_scripts/test_dashboard_publish.py", "docs/agents/e2e/test-case-catalog.yaml"] + out: ["Browser Playwright coverage", "Dashboard Web UI implementation", "Treating live LLM failure as automatic release blocker", "Runtime AI fixture generation", "Production or non-test database execution", "Raw logs in repo docs"] +bootstrap_context: + required_reads: ["AGENTS.md", "docs/agents/autonomous-development-workflow.yaml", "docs/agents/e2e/test-case-issue-loop.md", "docs/prds/2026-05-09-sql-to-dashboard-e2e-test-cases.md", "docs/prds/2026-05-06-workflow-erp-dashboard-builder.md"] + relevant_paths: ["docs/agents/issue-packets/issue-31.yaml", "docs/agents/handoffs/issue-31.yaml", "docs/agents/evidence/issue-31-pr-35.yaml", "tests/skill_scripts/test_governed_query.py", "tests/skill_scripts/test_chart_spec.py", "tests/skill_scripts/test_dashboard_canvas.py", "tests/skill_scripts/test_design_template_catalog.py", "tests/skill_scripts/test_dashboard_publish.py", "skill_scripts/governed_query.py", "skill_scripts/chart_spec.py", "skill_scripts/dashboard_canvas.py", "skill_scripts/design_template_catalog.py", "skill_scripts/dashboard_publish.py"] + prior_handoff: "docs/agents/handoffs/issue-31.yaml" + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} +implementation_notes: + constraints: + - "Live LLM governed-query evidence is required as compact evidence but non-blocking by default; deterministic regression remains the release-suitable gate." + - "Preserve Analyst Approval domain terms and existing guardrails; do not weaken assertions or expected outcomes." + - "Keep worker/verifier artifacts compact and index-only; no raw logs, SQL output, screenshots, or verbose transcripts." + risks: ["No browser Dashboard Web UI exists, so this issue must stay on CLI/library surfaces.", "Catalog already has a historical E2E-001 entry for issue #28; worker should update metadata without losing prior refs."] + dependencies: ["Issue #31 is released and closed; issue #34 remains blocked by issue #32."] +verification_plan: + worker_self_checks: + - {command: "python3 -m pytest tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q", purpose: "implementation feedback only", success_criteria: "Focused deterministic dashboard builder regression bucket passes before worker returns."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: + - {command: "rerun the deterministic prompt-to-published-dashboard target and focused dashboard regression bucket", success_criteria: "Verifier observes approved query -> MK006 bar chart -> canvas -> final approval -> published link render, with live LLM evidence classified separately."} + verifier_manual_qa: {owner: "pr_verifier", surface: "cli|library", happy_path: "Exercise the governed-query-to-published-link journey through public CLI/library surfaces.", refusal_or_error_path: "Confirm live LLM unavailable/failure evidence is classified separately and does not masquerade as product failure."} +handoff_contract: {repo_path: "docs/agents/handoffs/issue-32.yaml", github_summary_required: true} +result_contract: {template_path: "docs/agents/worker-result-template.yaml", verifier_template_path: "docs/agents/evidence-packet-template.yaml", allowed_statuses: [success, blocked, failed]} +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-09T22:07:00+08:00"} diff --git a/docs/agents/issue-packets/issue-5.yaml b/docs/agents/issue-packets/issue-5.yaml new file mode 100644 index 0000000..688f1c3 --- /dev/null +++ b/docs/agents/issue-packets/issue-5.yaml @@ -0,0 +1,161 @@ +schema_version: "1.0" +kind: issue_packet +legacy_note: + pre_enforcement: true + limitation: "Full-form issue packet retained from before compact line-cap enforcement; excluded from the generic compact-artifact gate." + +issue: + number: "5" + title: "Final Dashboard Approval and immutable publish" + url: "https://github.com/paulpai0412/wferp/issues/5" + labels: + - ready-for-agent + parent: + type: "prd" + reference: "https://github.com/paulpai0412/wferp/issues/1" + +branch: + name: "agent/issue-5-final-dashboard-approval-and-immutable-publish" + base: "main" + +objective: "Build the library-only final approval and immutable Published Dashboard tracer bullet on top of completed Chart Spec, Dashboard Canvas, and Design Template surfaces." + +acceptance_criteria: + - id: "AC1" + text: "A Dashboard draft can be previewed with approved Chart Specs, persisted Dashboard Canvas layout, and one Design Template." + evidence_hint: "Public module test constructs a draft from DashboardCanvas.to_payload()/load_dashboard_canvas(), DesignTemplateSelection, and chart payloads, then asserts the preview payload includes layout, chart specs, and template." + - id: "AC2" + text: "A Dashboard draft cannot become a Published Dashboard without final Analyst Approval." + evidence_hint: "State-transition test proves unapproved drafts cannot publish and returns a deterministic refusal code." + - id: "AC3" + text: "Publishing creates an immutable Published Dashboard version distinct from the mutable draft." + evidence_hint: "Test mutates or recreates the draft after publish and proves the published version payload remains unchanged and versioned." + - id: "AC4" + text: "SQL, Dashboard Data Source, or query-semantics changes require re-approval before republishing." + evidence_hint: "Tests mark data-meaning changes on a published draft and prove republish is blocked until a fresh final approval occurs." + - id: "AC5" + text: "Approval and publish events are recorded for audit." + evidence_hint: "Audit log test asserts final approval, rejection, publish, and re-approval-required events are recorded with stable event names and dashboard/version identifiers." + - id: "AC6" + text: "Workflow behavior is covered by tests for draft, approved, published, rejected, re-approval-required, and invalid transition paths." + evidence_hint: "Focused tests cover the state machine through public functions only; no private helper assertions." + +scope: + in: + - "skill_scripts/dashboard_publish.py or similarly focused library module for draft approval, immutable publish, and audit events." + - "tests/skill_scripts/test_dashboard_publish.py or equivalent focused public-interface tests." + - "docs/agents/handoffs/issue-5.yaml and docs/agents/evidence/issue-5-pr-.yaml after verifier acceptance." + out: + - "Browser UI, CLI commands, live production ERP queries, background refresh scheduler, published token-link runtime, and issue #6 link/view-only runtime behavior." + - "Changing legacy static schema docs or _Source generation." + - "Writing to production databases or adding arbitrary SQL execution." + +bootstrap_context: + required_reads: + - "AGENTS.md" + - "CONTEXT.md" + - "docs/agents/domain.md" + - "docs/agents/autonomous-development-workflow.yaml" + - "docs/prds/2026-05-06-workflow-erp-dashboard-builder.md" + - "docs/agents/handoffs/issue-3.yaml" + - "docs/agents/handoffs/issue-4.yaml" + - "docs/agents/handoffs/issue-7.yaml" + relevant_paths: + - "skill_scripts/chart_spec.py" + - "skill_scripts/dashboard_canvas.py" + - "skill_scripts/design_template_catalog.py" + - "skill_scripts/governed_query.py" + - "tests/skill_scripts/test_chart_spec.py" + - "tests/skill_scripts/test_dashboard_canvas.py" + - "tests/skill_scripts/test_design_template_catalog.py" + prior_handoff: "docs/agents/handoffs/issue-3.yaml, docs/agents/handoffs/issue-4.yaml, docs/agents/handoffs/issue-7.yaml" + +implementation_notes: + constraints: + - "Keep this issue library-only unless existing code clearly provides another surface; issue #6 owns token link/view-only runtime." + - "Reuse completed public surfaces: ChartSpec payloads, DashboardCanvas.to_payload()/load_dashboard_canvas(), and DesignTemplateSelection canonical payloads." + - "Published Dashboard must be an immutable version snapshot, not a mutable draft reference." + - "Final Analyst Approval is distinct from the query-result approval required before chart creation." + - "SQL, Dashboard Data Source, and query-semantics changes are data-meaning changes and must force re-approval before republish. Visual-only edits may be recorded but must not silently alter published data meaning." + - "Follow existing error-code style with deterministic RuntimeError messages." + - "Tests must exercise public functions/state transitions, not private helpers." + risks: + - "PRD mentions token links, refresh, and view-only runtime, but GitHub issue #5 is scoped to final approval and immutable publish; leave link/runtime for issue #6 unless needed as an opaque placeholder in published metadata." + - "Issue #3 and #7 evidence are pre-enforcement legacy; use their completed APIs, not their evidence as fresh verifier proof." + dependencies: + - "Closed: issue #3 Chart Spec authoring and validation." + - "Closed: issue #4 Design Template catalog and selection." + - "Closed: issue #7 Dashboard Canvas layout persistence." + +verification_plan: + worker_self_checks: + - command: "python3 -m py_compile skill_scripts/dashboard_publish.py tests/skill_scripts/test_dashboard_publish.py" + purpose: "implementation feedback only; not final acceptance QA" + success_criteria: "New module and focused tests compile." + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_publish.py -q" + purpose: "implementation feedback only; not final acceptance QA" + success_criteria: "Focused issue #5 public-interface tests pass." + - command: "python3 -m pytest tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q" + purpose: "implementation feedback only; not final acceptance QA" + success_criteria: "Dependency behavior and new publish workflow pass together." + verifier_acceptance_checks: + required_gates: + - "diagnostics_and_build_gate" + - "surface_qa_gate" + - "review_gate" + automated_checks: + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_publish.py -q" + success_criteria: "Verifier observes focused publish workflow coverage for all ACs." + - command: "python3 -m pytest tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q" + success_criteria: "Verifier observes dependency regression suite pass or records a precise blocker." + verifier_manual_qa: + owner: "pr_verifier" + surface: "library" + happy_path: "Run a minimal public API driver that builds a draft from approved chart/canvas/template payloads, final-approves it, publishes an immutable version, and renders/inspects the preview payload." + refusal_or_error_path: "Driver attempts publish before approval, republish after SQL/data-source/query-semantics change without re-approval, and invalid transition/rejected draft paths." + +handoff_contract: + repo_path: "docs/agents/handoffs/issue-5.yaml" + github_summary_required: true + required_sections: + - issue_identity + - acceptance_criteria_evidence + - compact_verification_summary + - evidence_packet_refs + - files_changed_summary + - blockers_or_risks + - next_recommended_step + +result_contract: + template_path: "docs/agents/worker-result-template.yaml" + verifier_template_path: "docs/agents/evidence-packet-template.yaml" + allowed_statuses: + - success + - blocked + - failed + required_fields: + - issue + - branch + - status + - summary + - files_changed + - verification + - pr + - blockers + - next_recommended_step + - evidence_packet_refs + - role_boundary + forbidden_payloads: + - full_worker_transcript + - full_verifier_transcript + - raw_test_logs + - verbose_manual_qa_transcript + +role_boundary: + orchestrator_may_validate_contract_only: true + worker_may_emit_final_acceptance: false + verifier_packet_required_for_completion: true + +prepared_by: + actor: "Hephaestus" + prepared_at: "2026-05-07T14:00:00+08:00" diff --git a/docs/agents/issue-packets/issue-6.yaml b/docs/agents/issue-packets/issue-6.yaml new file mode 100644 index 0000000..07a8ff7 --- /dev/null +++ b/docs/agents/issue-packets/issue-6.yaml @@ -0,0 +1,64 @@ +schema_version: "1.0" +kind: issue_packet +line_cap: 80 + +issue: + number: "6" + title: "Published Dashboard Link and view-only runtime" + url: "https://github.com/paulpai0412/wferp/issues/6" + labels: [ready-for-agent] + parent: {type: "prd", reference: "https://github.com/paulpai0412/wferp/issues/1"} + +branch: {name: "agent/issue-6-published-dashboard-link-runtime", base: "main"} + +objective: "Build the library/runtime tracer bullet for opaque Published Dashboard Links, view-only access, bounded refresh, denial states, and audit events." + +acceptance_criteria: + - {id: "AC1", text: "A Published Dashboard from issue #5 can receive an opaque, non-guessable token scoped to that Published Dashboard.", evidence_hint: "Public API test creates a published dashboard and link; token is opaque and scoped."} + - {id: "AC2", text: "A valid unexpired unrevoked token opens a view-only Published Dashboard without authoring controls.", evidence_hint: "Runtime render/driver asserts view_only true and no authoring actions."} + - {id: "AC3", text: "Revoked, expired, malformed, or unpublished tokens are denied without exposing dashboard data.", evidence_hint: "Refusal-path tests assert deterministic denial codes and no payload leak."} + - {id: "AC4", text: "Published Dashboard reads through a read-only data source on view and supports bounded auto-refresh.", evidence_hint: "Fake read-only data source records view/refresh calls and bounds."} + - {id: "AC5", text: "Out-of-policy refresh intervals are rejected and refresh failures show safe stale/error states.", evidence_hint: "Policy/failure tests cover too-fast/too-slow intervals and stale/error output."} + - {id: "AC6", text: "Link creation, denial, revocation, expiration, and refresh failure events are audited.", evidence_hint: "Audit log assertions use stable event names."} + +scope: + in: + - "Focused skill_scripts module(s) for link tokens, view-only runtime, refresh policy, and audit events." + - "Focused tests under tests/skill_scripts through public interfaces." + - "docs/agents/handoffs/issue-6.yaml and docs/agents/evidence/issue-6-pr-.yaml." + out: + - "Browser UI, auth/RBAC, production DB access, arbitrary SQL execution, scheduling service, external embedding SDK." + - "Changing legacy static schema docs or _Source generation." + +bootstrap_context: + required_reads: ["AGENTS.md", "CONTEXT.md", "docs/agents/autonomous-development-workflow.yaml", "docs/prds/2026-05-06-workflow-erp-dashboard-builder.md", "docs/agents/handoffs/issue-5.yaml"] + context_budget: {checkpoint_warning_at_percent: 45, stop_and_rotate_at_percent: 50} + relevant_paths: ["skill_scripts/dashboard_publish.py", "tests/skill_scripts/test_dashboard_publish.py"] + prior_handoff: "docs/agents/handoffs/issue-5.yaml" + +implementation_notes: + constraints: + - "Reuse issue #5 PublishedDashboard/version public surface; do not mutate published snapshots." + - "Use fake/read-only data source boundaries in tests unless existing DB validation surface is clearly required." + - "Tokens must be opaque/non-guessable; do not expose dashboard data on denial." + - "Use GIT_MASTER=1 prefix for git commands." + risks: ["Token links are weaker than authenticated access; include revoke/expire/audit behavior."] + dependencies: ["Closed: issue #5 final approval and immutable publish."] + +verification_plan: + worker_self_checks: + - {command: "python3 -m py_compile ", purpose: "implementation feedback only", success_criteria: "Changed modules compile."} + - {command: "python3 -m pytest -q", purpose: "implementation feedback only", success_criteria: "Focused public-interface tests pass."} + verifier_acceptance_checks: + required_gates: ["diagnostics_and_build_gate", "surface_qa_gate", "review_gate"] + automated_checks: [{command: "python3 -m pytest -q", success_criteria: "Verifier observes AC coverage pass."}] + verifier_manual_qa: + owner: "pr_verifier" + surface: "library" + happy_path: "Create a published dashboard link, resolve it, render view-only runtime, and observe bounded refresh." + refusal_or_error_path: "Try malformed, expired, revoked, unpublished tokens and refresh failure; confirm safe denial/stale state." + +handoff_contract: {repo_path: "docs/agents/handoffs/issue-6.yaml", github_summary_required: true} +result_contract: {template_path: "docs/agents/worker-result-template.yaml", verifier_template_path: "docs/agents/evidence-packet-template.yaml", allowed_statuses: [success, blocked, failed]} +role_boundary: {orchestrator_may_validate_contract_only: true, worker_may_emit_final_acceptance: false, verifier_packet_required_for_completion: true} +prepared_by: {actor: "Hephaestus", prepared_at: "2026-05-07T14:43:44+08:00"} diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..b9a231a --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,70 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues: + +- Repository: `paulpai0412/wferp` +- URL: +- Source skill setup: + +Use the `gh` CLI for issue operations when a skill says to publish to or fetch from the issue tracker. + +## Conventions + +- **Create an issue**: `gh issue create --repo paulpai0412/wferp --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --repo paulpai0412/wferp --comments`, filtering comments by `jq` and also fetching labels when needed. +- **List issues**: `gh issue list --repo paulpai0412/wferp --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --repo paulpai0412/wferp --body "..."`. +- **Apply / remove labels**: `gh issue edit --repo paulpai0412/wferp --add-label "..."` / `--remove-label "..."`. +- **Close**: `gh issue close --repo paulpai0412/wferp --comment "..."`. + +When running inside this clone, `gh` can also infer the repository from `git remote -v`. + +## Autonomous workflow tracker rules + +- PR bodies and issue comments may summarize verification only by referencing a verifier-owned evidence packet, for example `docs/agents/evidence/issue--pr-.yaml`. +- Do not paste raw test logs, browser traces, SQL execution logs, or verbose manual QA transcripts into issue comments or PR bodies. +- A final QA statement must identify the verifier packet and must not be written as a direct main-agent QA claim. +- If no verifier packet exists, the issue or PR must be marked blocked for missing verification evidence instead of reporting tests or QA as passed. +- Worker self-check summaries may be mentioned only as implementation feedback; they are not acceptance evidence unless independently confirmed by a verifier packet. +- `verifier_read_worker_result_only: false` is allowed when the verifier also reads compact refs such as the issue packet, PR diff, checkpoint, or PR page; it must not import full worker transcripts or raw logs. +- Artifact bodies must stay compact: issue packets <=80 lines, handoffs <=35 lines, evidence packets <=60 lines, checkpoints <=80 lines, and worker results <=80 lines. +- Raw evidence is index-only in repo docs and main-agent context; keep full logs/traces/screenshots in external artifact bundles referenced by manifest IDs. +- A `release_worker` may merge and close only after a verifier-owned evidence packet passes, the PR is mergeable, required checks pass, and human merge approval policy is satisfied; otherwise it must report blocked. +- Default merge approval mode is `human_required`. +- Autonomous workflow start may explicitly set `approval_override_mode: bypass_approval` for that workflow run only. +- `bypass_approval` may bypass only `human_merge_approval_policy_satisfied`; it must not bypass verifier pass, required checks, PR mergeability, review gate, diagnostics/build gate, or surface QA gate. +- `bypass_approval` must be declared at workflow start, recorded in the runtime checkpoint, remain immutable after start, and apply only to PRs created by that workflow run. +- Release summaries or PR comments for bypassed merges must record `merge_approval_mode`, `human_approval_skipped`, `override_source`, and `override_scope`. +- After a `release_worker` merge succeeds, it must run post-merge workspace hygiene before closing the linked issue. +- Post-merge workspace hygiene may modify only workspace hygiene state: preserve dirty primary workspace state, switch the primary workspace back to `main`, fast-forward `main`, and remove a clean merged issue worktree when safe. +- If the primary workspace is dirty, the `release_worker` should preserve it with a WIP branch named `agent/wip/post-merge-issue-{issue_number}-{yyyyMMdd-HHmm}` and a stash message `post-merge hygiene preserve issue-{issue_number} from {source_branch}` before restoring `main`. +- If post-merge workspace hygiene fails, the linked issue must remain open and the tracker comment must report a compact hygiene summary instead of claiming issue closure. +- Post-merge workspace hygiene summaries must use fixed fields in both repo-local checkpoint state and GitHub comments: `primary_workspace_branch_before`, `primary_workspace_branch_after`, `dirty_state_detected`, `wip_branch_created`, `stash_created`, `workspace_clean_after`, `issue_worktree_removed`, `cleanup_status`, and `blocked_reason`. +- `blocked_reason` values for hygiene failures should use fixed enums: `dirty_workspace_preserve_failed`, `switch_main_failed`, `fast_forward_main_failed`, `worktree_remove_failed`, `workspace_not_clean_after_cleanup`, and `issue_worktree_dirty_blocked`. +- Do not use `ultrawork` or any continuous autonomous loop to select and implement multiple `ready-for-agent` AFK issues concurrently from one orchestrator path. +- Until a dedicated concurrency policy exists, issue execution remains serial: one selected issue, one branch, one fresh worker session, one verifier session, and one PR at a time. + +### Post-merge workspace hygiene comment template + +Use this compact GitHub comment shape after `release_worker` merge-time cleanup completes or blocks: + +```md +## Post-merge workspace hygiene +- primary_workspace_branch_before: +- primary_workspace_branch_after: +- dirty_state_detected: +- wip_branch_created: +- stash_created: +- workspace_clean_after: +- issue_worktree_removed: +- cleanup_status: +- blocked_reason: +``` + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue in `paulpai0412/wferp`. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --repo paulpai0412/wferp --comments`. diff --git a/docs/agents/prd-issue-1-phase-audit.yaml b/docs/agents/prd-issue-1-phase-audit.yaml new file mode 100644 index 0000000..b483747 --- /dev/null +++ b/docs/agents/prd-issue-1-phase-audit.yaml @@ -0,0 +1,37 @@ +schema_version: "1.0" +kind: prd_phase_audit +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +prd: + issue_number: "1" + title: "PRD: Workflow ERP Dashboard Builder" + url: "https://github.com/paulpai0412/wferp/issues/1" + phase: "dashboard-builder-mvp" + +audit_summary: + status: "pass" + audit_scope: "issue coverage and user-story completeness before E2E test case issue materialization" + completed_issue_slices: ["2", "3", "4", "5", "6", "7", "20"] + missing_ready_for_agent_slices: [] + dependency_gaps: [] + e2e_test_case_issue_follow_up_required: ["AC17"] + +coverage_refs: + issue_2: "docs/agents/handoffs/issue-2.yaml + docs/agents/evidence/issue-2-pr-8.yaml" + issue_20: "docs/agents/handoffs/issue-20.yaml + docs/agents/evidence/issue-20-pr-21.yaml" + prd_issue_comment: "https://github.com/paulpai0412/wferp/issues/1#issuecomment-4396712209" + +acceptance_criteria_review: + - {ac_range: "AC1-AC6", status: "pass", evidence_ref: "issue_2", note: "Governed query generation, validation, execution evidence, and approval gating are traceable through reconstructed issue #2 refs."} + - {ac_range: "AC6-AC8", status: "pass", evidence_ref: "https://github.com/paulpai0412/wferp/issues/3 + https://github.com/paulpai0412/wferp/issues/4", note: "Chart authoring, validation, and Design Template selection are covered by closed tracer-bullet slices."} + - {ac_range: "AC7-AC10", status: "pass", evidence_ref: "https://github.com/paulpai0412/wferp/issues/5 + https://github.com/paulpai0412/wferp/issues/7", note: "Dashboard canvas persistence, final approval, and immutable publish flow are covered by closed slices."} + - {ac_range: "AC11-AC16", status: "pass", evidence_ref: "https://github.com/paulpai0412/wferp/issues/6", note: "Published Dashboard token runtime, revocation, refresh bounds, denial paths, and audit events are covered by the closed runtime slice."} + - {ac_range: "AC17", status: "follow_up_e2e_test_case_issues", evidence_ref: "docs/agents/e2e/test-case-issue-loop.md", note: "End-to-end MVP journey verification is handled through E2E test case issues, not a separate phase_full_e2e_and_qa phase."} + +compact_verification_summary: + ordering_check: "Workflow order is issue slices -> prd_phase_completeness_audit -> e2e_test_case_issue_loop -> phase_refactor." + coverage_result: "PRD #1 issue coverage is sufficient to leave issue generation and enter e2e_test_case_issue_loop before phase_refactor." + limitations: ["This audit does not claim AC17 is already satisfied; it records AC17 as the required E2E test case issue verification target."] + +next_recommended_step: "Convert E2E intents to GitHub issues via e2e_test_case_issue_loop, then proceed to phase_refactor after all test case issues pass." diff --git a/docs/agents/refactor/refactor-candidate-audit-template.yaml b/docs/agents/refactor/refactor-candidate-audit-template.yaml new file mode 100644 index 0000000..e05fd66 --- /dev/null +++ b/docs/agents/refactor/refactor-candidate-audit-template.yaml @@ -0,0 +1,32 @@ +schema_version: "1.0" +kind: refactor_candidate_audit +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +phase: "" +status: "candidates_found|skipped_no_candidates|blocked" + +inputs: + e2e_catalog_ref: "docs/agents/e2e/test-case-catalog.yaml" + failure_registry_ref: "docs/agents/e2e/failure-registry.yaml" + worker_or_verifier_suggestions: [""] + +risk_gate: + low_risk_policy: "high_or_medium_value_may_be_ready_for_agent" + medium_risk_policy: "requires_regression_bucket_and_rollback_plan" + high_risk_policy: "ready_for_human_or_split_smaller" + +candidates: + - id: "REF-001" + source: "failure_registry|worker_suggestion|verifier_suggestion|code_audit|human" + summary: "" + value: "high|medium|low" + risk: "high|medium|low" + affected_surfaces: ["cli|api|browser|library|sql|static-html|none"] + regression_bucket: [""] + issue: {number: "", url: ""} + status: "proposed|needs-triage|ready-for-agent|ready-for-human|done|wontfix" + +skip_reason: "" +blockers: [""] +next_recommended_step: "" diff --git a/docs/agents/refactor/refactor-candidate-loop.md b/docs/agents/refactor/refactor-candidate-loop.md new file mode 100644 index 0000000..ce2291e --- /dev/null +++ b/docs/agents/refactor/refactor-candidate-loop.md @@ -0,0 +1,132 @@ +# Refactor Candidate Loop + +## Status + +Draft workflow spec. This document records the agreed operating model for the +`phase_refactor` step that runs after `e2e_test_case_issue_loop` and before +documentation/tracker finalization. + +## Purpose + +Use refactor only as behavior-preserving architecture hygiene. The phase starts +with a compact audit, creates one GitHub issue per approved refactor candidate, +and routes each issue through the same worker, verifier, and release-worker +separation used by feature and E2E test case issues. + +This phase does not restore `phase_full_e2e_and_qa`. E2E test case issues are +the phase-level E2E mechanism; refactor verification uses impact-based +regression over the affected buckets. + +## Lifecycle + +1. **Prerequisite** + - `e2e_test_case_issue_loop` has passed or released all materialized E2E test + case issues. + - Product behavior is frozen for the PRD phase. + +2. **Audit candidates** + - Run a refactor audit over completed issue evidence, failure registry, + worker/verifier suggestions, and code structure. + - Record the result in a compact audit artifact under `docs/agents/refactor/`. + +3. **Risk gate** + - Low-risk, high/medium-value candidates may become `ready-for-agent`. + - Medium-risk candidates need a clear regression bucket and rollback plan. + - High-risk candidates become `ready-for-human` or are split smaller. + +4. **Issue execution** + - Create one GitHub issue per approved candidate. + - The issue worker may perform only safety refactor changes. + - A fresh verifier validates behavior using impact-based regression. + - The release worker merges only after verifier evidence passes. + +5. **Metadata update** + - If a refactor resolves an E2E hotspot, update E2E catalog/registry metadata + refs only. + - Do not change E2E expected outcomes or acceptance criteria inside refactor. + +6. **Skip path** + - If no candidates exist, write a compact skipped audit and continue to + `documentation_and_tracker_update`. + +## Candidate sources + +- Duplicated logic. +- Unstable E2E repair hotspots. +- High `failure_signature` concentration. +- Tightly coupled modules. +- Hard-to-test public surfaces. +- Repeated fixture or harness repair. +- Long or fragile functions. +- Unclear module boundaries. +- Worker or verifier suggestions, queued for audit before issue creation. + +## Allowed safety refactors + +- Rename symbols or files without changing public behavior. +- Extract functions, classes, or modules. +- Remove duplication. +- Simplify control flow. +- Improve boundaries and reduce coupling. +- Improve testability without changing expected behavior. + +## Forbidden changes + +- New product behavior. +- UX changes. +- SQL semantics changes. +- Public API or CLI contract changes. +- Expected output changes. +- Acceptance criteria changes. +- E2E expected outcome changes. + +If a product or design change is required, stop refactor and create a new issue +or PRD/spec repair issue. + +## Impact-based regression + +The refactor verifier maps changed paths and public surfaces to affected +regression buckets: + +```yaml +impact_based_regression: + changed_paths: [""] + affected_surfaces: ["cli|api|browser|library|sql|static-html"] + required_buckets: [""] + uncertain_impact_fallback: "rerun_all_materialized_e2e_test_case_regression_buckets" +``` + +If impact is unclear, rerun all materialized E2E test case regression buckets. + +## Labels + +Refactor issues use canonical triage labels plus supplemental labels: + +| Supplemental label | Meaning | +| --- | --- | +| `refactor-candidate` | Behavior-preserving refactor candidate. | +| `blocked-by-spec` | Needs PRD/spec decision before refactor can continue. | +| `blocked-by-risk` | Risk gate failed; split smaller or send to human. | + +## Pass condition + +```yaml +pass_condition: + safety_refactor_only: true + expected_behavior_unchanged: true + impact_based_regression: pass + verifier_packet: pass + proof_of_separation: true +``` + +## Integration points + +- `docs/agents/autonomous-development-workflow.yaml`: owns phase ordering, + risk gate, and issue loop policy. +- `docs/agents/refactor/refactor-candidate-audit-template.yaml`: compact audit + artifact shape. +- `docs/agents/triage-labels.md`: supplemental refactor labels. +- `docs/agents/e2e/test-case-catalog.yaml`: optional metadata refs to completed + refactor issues. +- `docs/agents/e2e/failure-registry.yaml`: optional `refactor_issue_refs` when a + refactor fixes a failure hotspot. diff --git a/docs/agents/runtime/context-checkpoint.yaml b/docs/agents/runtime/context-checkpoint.yaml new file mode 100644 index 0000000..1850391 --- /dev/null +++ b/docs/agents/runtime/context-checkpoint.yaml @@ -0,0 +1,79 @@ +schema_version: "1.0" +kind: context_checkpoint +line_cap: 80 + +subject: + issue_number: "32" + branch: "agent/issue-32-prompt-to-published-dashboard-e2e" + role: "main_orchestrator" + checkpoint_reason: "phase1_issue_selection_issue32" + +context_budget: + warning_at_percent: 45 + stop_and_rotate_at_percent: 50 + measured_percent_used: "unknown" + must_rotate_now: false + +resume_policy: + checkpoint_only_cross_session_resume: true + do_not_import_full_prior_transcript: true + raw_evidence_policy: "index_only; raw logs/traces stay in artifact bundle" + +runtime_controls: + approval_override_mode: "bypass_approval" + default_merge_approval_mode: "human_required" + set_only_at_workflow_start: true + mutable_after_start: false + scope: "workflow_run_only" + applies_to: "all_prs_created_by_this_run" + affects_stage: "release_worker_only" + override_source: "workflow_start" + human_approval_skipped: true + +state: + completed: + - "Issue #16 context budget and release_worker workflow merged via PR #17." + - "Issue #6 completed full worker -> verifier -> release_worker flow and merged via PR #18." + - "Issue #20 rebuilt repo-local issue #2 traceability and merged via PR #21 (acb13e1)." + - "PRD #1 phase audit passed; AC17 follow-up moved to the E2E test case issue loop." + - "Issue #28 / E2E-001 passed verifier evidence, merged via PR #29 (5ba3269), and the issue is now closed." + - "Issue #31 / E2E-002 passed verifier evidence and merged via PR #35 (3813948)." + in_progress: + - "Issue #32 selected as next unblocked ready-for-agent E2E issue after #31 release." + next: + - "Spawn a fresh issue worker for issue #32 using docs/agents/issue-packets/issue-32.yaml." + blockers: + - "Issue #34 remains blocked by issue #32; issue #33 is also unblocked but waits due serial execution." + +refs: + issue_packet: "docs/agents/issue-packets/issue-32.yaml" + worker_result: "" + evidence_packet: "" + handoff: "docs/agents/handoffs/issue-32.yaml" + artifact_bundle: "https://github.com/paulpai0412/wferp/issues/32" + +compact_payload: + active_target: {issue_number: "32", branch: "agent/issue-32-prompt-to-published-dashboard-e2e", role: "main_orchestrator", next_flow: "per_issue_flow"} + authoritative_refs: ["docs/agents/issue-packets/issue-32.yaml", "docs/agents/handoffs/issue-32.yaml", "docs/agents/autonomous-development-workflow.yaml"] + state_snapshot: + completed: ["Issue #31 verifier evidence passed and PR #35 merged at 3813948."] + in_progress: ["Issue #32 selected and packet/handoff prepared for fresh worker pickup."] + next: ["Spawn a fresh issue worker for issue #32."] + blockers: ["Issue #34 remains blocked by issue #32; issue #33 waits due serial execution."] + resume_rules: ["Resume from checkpoint and compact refs, not full chat history.", "Keep raw evidence as refs only; do not inline logs or traces."] + immediate_next_action: "Spawn a fresh issue worker for issue #32 using docs/agents/issue-packets/issue-32.yaml." + +post_merge_workspace_hygiene: + primary_workspace_branch_before: "agent/issue-31-deterministic-actmk-sql-correctness" + primary_workspace_branch_after: "main" + dirty_state_detected: true + wip_branch_created: "agent/wip/post-merge-issue-31-20260509-2135" + stash_created: "stash@{0}" + workspace_clean_after: true + issue_worktree_removed: true + cleanup_status: "pass" + blocked_reason: "none" + +metadata: + updated_by: "Hephaestus" + updated_at: "2026-05-09T22:07:00+08:00" diff --git a/docs/agents/runtime/phase-1-orchestrator-checkpoint-to-new-session.md b/docs/agents/runtime/phase-1-orchestrator-checkpoint-to-new-session.md new file mode 100644 index 0000000..0c33125 --- /dev/null +++ b/docs/agents/runtime/phase-1-orchestrator-checkpoint-to-new-session.md @@ -0,0 +1,181 @@ +# Phase 1 orchestrator checkpoint-to-new-session spec + +## Status + +- Status: active MVP +- Owner: main orchestrator session +- Source of truth: `docs/agents/autonomous-development-workflow.yaml#context_budget_policy` + +## Purpose + +Define the minimum executable contract for Phase 1 of the autonomous workflow: + +1. select the next ready AFK issue +2. persist a checkpoint for the selected issue +3. derive a compact payload from the checkpoint +4. write a fresh-session continuation request for the plugin +5. continue into the selected issue PR flow from a fresh session + +This phase reduces orchestrator context before new issue PR work by switching from checkpoint state into a fresh session instead of relying on native compaction. + +## Applies to + +- `main_orchestrator` only + +## Does not apply to + +- issue worker sessions +- PR verifier sessions +- phase verifier sessions +- release worker sessions +- no-ready-issue PRD completeness audit path + +## Trigger + +Trigger this flow only when all conditions are true: + +1. the orchestrator is inside `issue_development_loop` +2. `issue_selection` returned an issue labeled `ready-for-agent` +3. the selected slice type is `AFK` +4. the orchestrator is about to enter `per_issue_flow` + +Do not trigger from branch changes, git hooks, PR webhooks, or generic session events. + +## Required inputs + +- selected issue number +- selected issue branch name +- selected issue packet path +- latest prior handoff path when present +- current workflow policy path +- current orchestrator role + +## Required outputs + +- updated `docs/agents/runtime/context-checkpoint.yaml` +- derived compact payload embedded in the checkpoint +- written `.opencode/runtime/new-session-request.json` +- fresh orchestrator session ready to continue `per_issue_flow` + +## Current implementation reference + +- payload derivation and checkpoint rewrite helper: `scripts/phase1_compact_payload.py` +- orchestrator runner: `scripts/phase1_orchestrator_runner.py` +- continuation plugin: `.opencode/plugins/session-continuation.ts` +- OpenCode project command: `.opencode/commands/phase1-start.md` + +## Algorithm + +1. Run `issue_selection`. +2. If no `ready-for-agent` AFK issue exists, do not start `per_issue_flow`; return control to `prd_phase_completeness_audit` in the main workflow. +3. Populate `context-checkpoint.yaml` for the selected issue. +4. Derive `compact_payload` from the checkpoint using the rules below. +5. Write `.opencode/runtime/new-session-request.json` with a checkpoint-only bootstrap prompt. +6. Let the session-continuation plugin consume the request on `session.idle` and create a fresh root session. +7. Continue `per_issue_flow` in the fresh session in this order: + - `create_or_switch_issue_branch` + - `build_issue_packet` + - `spawn_fresh_issue_worker` + - remaining verifier and handoff steps already defined in workflow policy + +If any step before request generation fails, stop and report blocked; do not start worker execution. + +When step 2 routes to `prd_phase_completeness_audit`, this Phase 1 checkpoint-to-new-session contract is not the active flow. In that branch, the orchestrator should resume from PRD/workflow references and the latest phase handoff rather than forcing an issue packet-shaped payload. + +## Compact payload contract + +The orchestrator must not inject the full checkpoint body into the continuation prompt. It must derive a compact payload with exactly these sections: + +### 1. active_target + +- `issue_number` +- `branch` +- `role` +- `next_flow` + +### 2. authoritative_refs + +Must include only canonical resume entry points: + +- issue packet +- prior handoff when present +- workflow policy + +Must not include full transcript exports, raw logs, or ad hoc search dumps. + +### 3. state_snapshot + +- `completed` +- `in_progress` +- `next` +- `blockers` + +Keep each list short and action-oriented. + +### 4. resume_rules + +Must restate these rules in compact form: + +- resume from checkpoint and compact payload, not full chat history +- keep raw evidence as refs only +- do not inline logs, traces, or long transcripts + +### 5. immediate_next_action + +One imperative sentence that tells the fresh session what to do first. + +## Checkpoint and continuation request write rules + +When updating `context-checkpoint.yaml` and writing the continuation request, the orchestrator must: + +- set `subject.role` to `main_orchestrator` +- record the selected issue and branch +- keep the checkpoint file within the 80-line cap +- update `metadata.updated_at` +- keep refs stable and canonical +- write the continuation request under `.opencode/runtime/new-session-request.json` +- ensure the request prompt explicitly says checkpoint-only bootstrap and forbids prior transcript import + +## Failure handling + +### Checkpoint update failure + +- status: blocked +- action: stop before continuation request write +- report: checkpoint write failure and missing fields + +### Compact payload derivation failure + +- status: blocked +- action: stop before continuation request write +- report: which required payload section is missing + +### Continuation request write failure + +- status: blocked +- action: stop before worker spawn +- report: request path write failed or required fields were missing + +### Fresh-session bootstrap failure + +- status: blocked +- action: stop before worker spawn +- report: plugin failed to create the root session or prompt it from checkpoint + +## Non-goals + +- native session compaction for Phase 1 startup +- automatic `>50%` context auto-rotation outside this flow +- hook-driven orchestration beyond consuming the explicit continuation request +- branch-change or webhook-driven passive triggering + +## Active handoff contract + +Phase 1 now uses this order: + +1. write checkpoint +2. write fresh-session continuation request +3. plugin creates a fresh orchestrator session from checkpoint +4. fresh session continues `per_issue_flow` + +This replaces the previous same-session startup behavior. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..955fa24 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,37 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's GitHub issue tracker. + +Source skill setup: + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role, use the corresponding label string from this table. + +## E2E test case labels + +E2E-specific labels may add workflow detail, but they must not replace the +canonical triage labels above. + +| Supplemental label | Meaning | +| ------------------ | ------- | +| `e2e-test-case` | One executable E2E test case issue. | +| `blocked-by-spec` | Expected behavior or acceptance criteria need PRD/spec repair. | +| `blocked-by-infra` | Shared harness, fixture, environment, or infrastructure failure blocks verification. | + +## Refactor labels + +Refactor-specific labels may add workflow detail, but they must not replace the +canonical triage labels above. `blocked-by-spec` is shared with E2E issues when +the blocker is a product or PRD/spec decision. + +| Supplemental label | Meaning | +| -------------------- | ------- | +| `refactor-candidate` | One behavior-preserving refactor candidate issue from the phase refactor audit. | +| `blocked-by-risk` | Refactor candidate failed the risk gate; split smaller or route to human. | diff --git a/docs/agents/worker-result-template.yaml b/docs/agents/worker-result-template.yaml new file mode 100644 index 0000000..27bc406 --- /dev/null +++ b/docs/agents/worker-result-template.yaml @@ -0,0 +1,73 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +issue: + number: "" + title: "" + url: "" + +branch: "agent/issue--" +status: "" + +summary: + objective: "" + outcome: "" + +files_changed: + - path: "" + summary: "" + +test_case_repair: + applies: false + test_case_id: "" + attempted_target_case: false + repaired_categories: [""] + failure_signature: "" + assertion_changed: false + expected_outcome_changed: false + +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: + tdd_gate: "" + implementation_self_check_gate: "" + git_gate: "" + implementation_self_checks: + - command: "" + result: "" + evidence_ref: "" + summary: "" + final_acceptance_claim: false + +evidence_packet_refs: + worker_artifact_bundle: "" + verifier_packet: "docs/agents/evidence/issue--pr-.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" + +role_boundary: + actor_role: "issue_worker" + may_execute_implementation_self_checks: true + may_execute_final_acceptance_qa: false + may_emit_final_verification: false + verifier_packet_required_for_completion: true + +pr: + number: "" + url: "" + ready_for_review: false + +blockers: + - "" + +next_recommended_step: "" + +handoff: + repo_path: "docs/agents/handoffs/issue-.yaml" + github_summary_posted: false + +metadata: + worker: "" + worker_session_id: "" + completed_at: "" diff --git a/docs/agents/worker-results/issue-20.yaml b/docs/agents/worker-results/issue-20.yaml new file mode 100644 index 0000000..4bda0f1 --- /dev/null +++ b/docs/agents/worker-results/issue-20.yaml @@ -0,0 +1,58 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: + number: "20" + title: "Reconstruct governed query traceability artifacts" + url: "https://github.com/paulpai0412/wferp/issues/20" +branch: "agent/issue-20-reconstruct-governed-query-traceability" +status: "success" +summary: + objective: "Rebuild compact repo-local traceability for issue #2 / PR #8 without re-implementing governed query behavior." + outcome: "Added compact issue #2 evidence/handoff artifacts, refreshed the workflow checkpoint for the PRD audit, and prepared issue #20 worker handoff docs without claiming fresh product acceptance." +files_changed: + - path: "docs/agents/issue-packets/issue-20.yaml" + summary: "Captures the workflow self-repair scope, evidence sources, and verifier-owned acceptance contract for issue #20." + - path: "docs/agents/evidence/issue-2-pr-8.yaml" + summary: "Indexes historical issue #2 / PR #8 acceptance evidence and limitations from durable GitHub and merged-repo sources." + - path: "docs/agents/handoffs/issue-2.yaml" + summary: "Maps issue #2 acceptance criteria to the retrospective evidence packet for PRD coverage review." + - path: "docs/agents/runtime/context-checkpoint.yaml" + summary: "Updates the compact checkpoint so the PRD audit can resume from the reconstructed issue #2 refs." + - path: "docs/agents/handoffs/issue-20.yaml" + summary: "Records issue #20 worker-side traceability, self-check status, and verifier handoff." + - path: "docs/agents/worker-results/issue-20.yaml" + summary: "Stores the structured worker result for this workflow self-repair slice." + - path: "scripts/agent_context_budget_check.py" + summary: "Extends artifact-cap validation to the new issue #2 and issue #20 compact artifacts." +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: + tdd_gate: "not_applicable" + implementation_self_check_gate: "pass" + git_gate: "pass" + implementation_self_checks: + - command: "python3 scripts/agent_context_budget_check.py" + result: "pass" + evidence_ref: "local-worker:self-check-context-budget" + summary: "Artifact cap script passed after adding the compact issue #2 and issue #20 documents." + - command: "python3 -m py_compile scripts/agent_context_budget_check.py" + result: "pass" + evidence_ref: "local-worker:self-check-py-compile" + summary: "The updated workflow self-check script compiled successfully." + - command: "lsp_diagnostics on changed YAML and Python files" + result: "pass" + evidence_ref: "local-worker:self-check-lsp" + summary: "No diagnostics remained on the changed issue packet, handoff, evidence, checkpoint, worker-result, or script files." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-20.yaml" + verifier_packet: "docs/agents/evidence/issue-20-pr-21.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "21", url: "https://github.com/paulpai0412/wferp/pull/21", ready_for_review: true} +blockers: ["A fresh verifier packet is still required for issue #20 before final acceptance can be claimed."] +next_recommended_step: "Push the issue #20 branch, open the PR, and hand it to a fresh verifier that can validate the reconstructed traceability artifacts." +handoff: {repo_path: "docs/agents/handoffs/issue-20.yaml", github_summary_posted: true} +metadata: {worker: "Sisyphus-Junior", worker_session_id: "worker-result:issue-20", completed_at: "2026-05-07T18:45:00+08:00"} diff --git a/docs/agents/worker-results/issue-28.yaml b/docs/agents/worker-results/issue-28.yaml new file mode 100644 index 0000000..1632d6f --- /dev/null +++ b/docs/agents/worker-results/issue-28.yaml @@ -0,0 +1,44 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: {number: "28", title: "E2E: Validate dashboard builder MVP journey", url: "https://github.com/paulpai0412/wferp/issues/28"} +branch: "agent/issue-28-ac17-e2e-test-case" +status: "success" +summary: + objective: "Materialize PRD #1 AC17 as one executable E2E target case for the Dashboard Builder MVP journey." + outcome: "Added one focused CLI/library pytest target case for E2E-001, then recorded compact worker artifacts after focused dashboard-builder self-checks passed and PR #29 was opened." +files_changed: + - {path: "tests/skill_scripts/test_dashboard_builder_e2e.py", summary: "Covers the AC17 prompt-to-published-dashboard MVP journey and refusal gates before charting and publishing."} + - {path: "docs/agents/issue-packets/issue-28.yaml", summary: "Preserves the orchestrator-issued E2E issue packet on the branch."} + - {path: "docs/agents/e2e/test-case-catalog.yaml", summary: "Advances E2E-001 metadata to the active PR #29 worker state."} + - {path: "docs/agents/runtime/context-checkpoint.yaml", summary: "Points the workflow handoff at the issue #28 worker result and pending PR #29 verifier step."} + - {path: "docs/agents/worker-results/issue-28.yaml", summary: "Stores the structured worker result for the issue #28 E2E worker."} + - {path: "docs/agents/handoffs/issue-28.yaml", summary: "Provides the compact issue handoff for the orchestrator and fresh verifier."} +test_case_repair: + applies: true + test_case_id: "E2E-001" + attempted_target_case: true + repaired_categories: ["test"] + failure_signature: "none" + assertion_changed: false + expected_outcome_changed: false +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: {tdd_gate: "pass", implementation_self_check_gate: "pass", git_gate: "pass"} + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_builder_e2e.py tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-e2e-001-regression" + summary: "Target case plus the required regression bucket passed 46/46." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-28.yaml" + verifier_packet: "docs/agents/evidence/issue-28-pr-29.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "29", url: "https://github.com/paulpai0412/wferp/pull/29", ready_for_review: true} +blockers: ["Fresh verifier evidence for PR #29 is still required before AC17 can be claimed as passed for issue #28."] +next_recommended_step: "Validate this worker result schema, then spawn a distinct verifier for PR #29 to rerun E2E-001 and its regression bucket." +handoff: {repo_path: "docs/agents/handoffs/issue-28.yaml", github_summary_posted: false} +metadata: {worker: "Sisyphus-Junior", worker_session_id: "worker-result:issue-28", completed_at: "2026-05-09T17:10:17+08:00"} diff --git a/docs/agents/worker-results/issue-31.yaml b/docs/agents/worker-results/issue-31.yaml new file mode 100644 index 0000000..0902308 --- /dev/null +++ b/docs/agents/worker-results/issue-31.yaml @@ -0,0 +1,52 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: {number: "31", title: "E2E: Deterministic ACTMK fixture and SQL correctness", url: "https://github.com/paulpai0412/wferp/issues/31"} +branch: "agent/issue-31-deterministic-actmk-sql-correctness" +status: "success" +summary: + objective: "Materialize E2E-002 by proving deterministic 2026 ACTMK budget correctness through the existing governed-query test-db path." + outcome: "Confirmed the branch already adds deterministic ACTMK seed data plus result-based DB_ENV=test assertions, reran the focused worker self-check bucket, and recorded compact worker artifacts for PR #35 without claiming final acceptance." +files_changed: + - {path: "test_db/init/01_create_wferp_test.sql", summary: "Seeds three deterministic 2026 ACTMK rows totaling 450000 plus one 2025 contrast row for SQL correctness coverage."} + - {path: "skill_scripts/database_client.py", summary: "Preserves read-only test DB execution while parsing connection-string fields needed by the deterministic SQL correctness path."} + - {path: "tests/skill_scripts/test_sql_correctness_e2e.py", summary: "Executes the real DB_ENV=test governed-query path and asserts MK002/MK006 columns, three 2026 rows, sum(MK006)=450000, and 2025 contrast-row exclusion."} + - {path: "tests/skill_scripts/test_database_client.py", summary: "Covers the connection parsing and read-only execution behavior used by the ACTMK target case."} + - {path: "docs/agents/worker-results/issue-31.yaml", summary: "Stores the structured worker result for issue #31."} + - {path: "docs/agents/handoffs/issue-31.yaml", summary: "Updates the compact issue handoff from pending worker boot state to waiting for fresh verifier evidence."} +test_case_repair: + applies: true + test_case_id: "E2E-002" + attempted_target_case: true + repaired_categories: ["fixture", "test", "harness"] + failure_signature: "none" + assertion_changed: false + expected_outcome_changed: false +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: {tdd_gate: "pass", implementation_self_check_gate: "pass", git_gate: "pass"} + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_database_client.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-31-db-client" + summary: "Focused database-client regression passed 7/7." + - command: "WFERP_RUN_DB_E2E=1 python3 -m pytest tests/skill_scripts/test_sql_correctness_e2e.py tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_execution_validator.py tests/skill_scripts/test_sql_router.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-31-sql-correctness" + summary: "Deterministic ACTMK target case plus required regression bucket passed 25/25 in DB_ENV=test." + - command: "lsp_diagnostics on changed Python files" + result: "pass" + evidence_ref: "local-worker:self-check-issue-31-lsp" + summary: "Changed implementation/test files had no blocking diagnostics; one test-file basedpyright warning remains non-blocking around db_client typing." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-31.yaml" + verifier_packet: "docs/agents/evidence/issue-31-pr-35.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "35", url: "https://github.com/paulpai0412/wferp/pull/35", ready_for_review: true} +blockers: ["Fresh verifier evidence for PR #35 is still required before issue #31 can be claimed as fully passed."] +next_recommended_step: "Validate this worker result schema, then spawn a distinct verifier for PR #35 to rerun E2E-002 and its focused SQL regression bucket." +handoff: {repo_path: "docs/agents/handoffs/issue-31.yaml", github_summary_posted: false} +metadata: {worker: "OpenCode-gpt-5.4", worker_session_id: "worker-result:issue-31", completed_at: "2026-05-09T21:10:29+08:00"} diff --git a/docs/agents/worker-results/issue-32.yaml b/docs/agents/worker-results/issue-32.yaml new file mode 100644 index 0000000..e82048a --- /dev/null +++ b/docs/agents/worker-results/issue-32.yaml @@ -0,0 +1,55 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: {number: "32", title: "E2E: Prompt-to-Published-Dashboard live LLM evidence", url: "https://github.com/paulpai0412/wferp/issues/32"} +branch: "agent/issue-32-prompt-to-published-dashboard-e2e" +status: "success" +summary: + objective: "Materialize the CLI/library prompt-to-published-dashboard E2E path while recording live LLM governed-query evidence as non-blocking evidence and deterministic regression as the release gate." + outcome: "Added compact live LLM governed-query evidence classification beside the deterministic prompt-to-published-dashboard target case, reran worker self-checks, updated E2E workflow artifacts, and opened PR #36 without claiming final acceptance." +files_changed: + - {path: "skill_scripts/governed_query.py", summary: "Adds a public compact live LLM governed-query evidence helper that classifies LLM availability, infra, test-data, and product failures separately."} + - {path: "tests/skill_scripts/test_dashboard_builder_e2e.py", summary: "Extends E2E-001 coverage with non-blocking live LLM failure classification while preserving the deterministic MK006 bar-chart to Published Dashboard Link flow."} + - {path: "docs/agents/e2e/test-case-catalog.yaml", summary: "Moves E2E-001 active metadata to issue #32 while preserving issue #28 verifier evidence as a historical ref."} + - {path: "docs/agents/worker-results/issue-32.yaml", summary: "Stores the structured issue #32 worker result."} + - {path: "docs/agents/handoffs/issue-32.yaml", summary: "Updates the compact handoff for fresh verifier pickup after the worker PR."} +test_case_repair: + applies: true + test_case_id: "E2E-001" + attempted_target_case: true + repaired_categories: ["test", "harness"] + failure_signature: "none" + assertion_changed: false + expected_outcome_changed: false +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: {tdd_gate: "pass", implementation_self_check_gate: "pass", git_gate: "pass"} + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_builder_e2e.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-32-target-case" + summary: "Target case passed 2/2, covering deterministic prompt-to-published-dashboard flow and compact non-blocking live LLM failure evidence." + - command: "python3 -m pytest tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py tests/skill_scripts/test_design_template_catalog.py tests/skill_scripts/test_dashboard_publish.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-32-regression-bucket" + summary: "Required deterministic dashboard-builder regression bucket passed 45/45." + - command: "python3 -m py_compile skill_scripts/governed_query.py tests/skill_scripts/test_dashboard_builder_e2e.py" + result: "pass" + evidence_ref: "local-worker:self-check-issue-32-py-compile" + summary: "Changed Python files compiled without errors." + - command: "lsp_diagnostics on changed Python test file and implementation file" + result: "pass" + evidence_ref: "local-worker:self-check-issue-32-lsp" + summary: "Changed test file had no diagnostics; implementation file retains pre-existing typing warnings outside the new helper." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-32.yaml" + verifier_packet: "docs/agents/evidence/issue-32-pr-36.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "36", url: "https://github.com/paulpai0412/wferp/pull/36", ready_for_review: true} +blockers: ["Fresh verifier evidence is still required before issue #32 can be claimed as accepted."] +next_recommended_step: "Spawn a distinct verifier for PR #36 to rerun E2E-001 and the focused dashboard regression bucket." +handoff: {repo_path: "docs/agents/handoffs/issue-32.yaml", github_summary_posted: false} +metadata: {worker: "OpenCode-gpt-5.5", worker_session_id: "worker-result:issue-32", completed_at: "2026-05-09T23:24:00+08:00"} diff --git a/docs/agents/worker-results/issue-33.yaml b/docs/agents/worker-results/issue-33.yaml new file mode 100644 index 0000000..ad7c16a --- /dev/null +++ b/docs/agents/worker-results/issue-33.yaml @@ -0,0 +1,55 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: {number: "33", title: "E2E: Query Analyst Approval gate before Chart Spec", url: "https://github.com/paulpai0412/wferp/issues/33"} +branch: "agent/issue-33-e2e-query-analyst-approval-gate-before-chart-spe" +status: "success" +failure_classification: {kind: "none", retryable: false, routed_to: "pr_verifier", root_cause_signature: "none"} +summary: + objective: "Materialize E2E-003 for the current CLI/library Dashboard Builder surface by proving Chart Spec creation is gated on Analyst Approval for the governed 2026 engineering-budget query result." + outcome: "Verified the branch adds an additive E2E-003 library test that refuses both Chart Spec authoring entry points before Analyst Approval and succeeds after approval with MK006 as the bar-value field; worker self-checks passed on HEAD 0200368 and PR #37 is ready for fresh verifier review." +files_changed: + - {path: "tests/skill_scripts/test_query_approval_chart_spec_e2e.py", summary: "Adds the focused E2E-003 target case covering governed-query creation, refusal before approval, and success after approval using MK006 in the bar chart payload."} + - {path: "docs/agents/e2e/test-case-catalog.yaml", summary: "Registers E2E-003 as in-progress for issue #33 so orchestrator/verifier flows can reference the issue-scoped test case."} + - {path: "docs/agents/worker-results/issue-33.yaml", summary: "Stores the compact worker result for issue #33 without making a final acceptance claim."} +test_case_repair: + applies: true + test_case_id: "E2E-003" + attempted_target_case: true + repaired_categories: ["test"] + failure_signature: "none" + assertion_changed: false + expected_outcome_changed: false +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: {tdd_gate: "pass", implementation_self_check_gate: "pass", git_gate: "pass"} + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_query_approval_chart_spec_e2e.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-33-target-case-0200368" + summary: "Target case passed 2/2 covering governed-query creation, refusal before approval, and approval-unlocked MK006 bar Chart Spec authoring." + - command: "python3 -m pytest tests/skill_scripts/test_governed_query.py tests/skill_scripts/test_chart_spec.py tests/skill_scripts/test_dashboard_canvas.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-33-regression-bucket-0200368" + summary: "Focused dashboard-builder regression bucket passed 25/25 without weakening governed-query, Chart Spec, or Dashboard Canvas assertions." + - command: "python3 -m py_compile tests/skill_scripts/test_query_approval_chart_spec_e2e.py" + result: "pass" + evidence_ref: "local-worker:self-check-issue-33-py-compile-0200368" + summary: "Changed Python target-case test compiled cleanly." + - command: "lsp_diagnostics severity=error on changed files" + result: "pass" + evidence_ref: "local-worker:self-check-issue-33-lsp-0200368" + summary: "Changed test file and updated catalog YAML reported no error-level diagnostics." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-33.yaml" + verifier_packet: "docs/agents/evidence/issue-33-pr-37.yaml" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "37", url: "https://github.com/paulpai0412/wferp/pull/37", ready_for_review: true} +blockers: + - "Fresh verifier evidence for PR #37 is still required before issue #33 can be claimed as accepted." +next_recommended_step: "Validate this worker result schema, then spawn a fresh pr_verifier for PR #37 to rerun E2E-003 and the focused dashboard-builder regression bucket." +handoff: {repo_path: "docs/agents/handoffs/issue-33.yaml", github_summary_posted: false} +metadata: {worker: "Sisyphus-issue_worker", worker_session_id: "worker:issue-33-20260510-2310", completed_at: "2026-05-10T23:10:02+08:00"} diff --git a/docs/agents/worker-results/issue-34.yaml b/docs/agents/worker-results/issue-34.yaml new file mode 100644 index 0000000..2f4415d --- /dev/null +++ b/docs/agents/worker-results/issue-34.yaml @@ -0,0 +1,45 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts +issue: {number: "34", title: "E2E: Revoked Published Dashboard Link denial", url: "https://github.com/paulpai0412/wferp/issues/34"} +branch: "agent/issue-34-revoked-published-dashboard-link-denial" +status: "success" +summary: + objective: "Materialize E2E-004 for the current library Dashboard Builder surface by proving pre-revocation view-only render and post-revocation denied access without payload leakage." + outcome: "Confirmed the existing library-surface revoked-link coverage already satisfies E2E-004 on current HEAD, absorbed the user-authorized deletion of docs/agents/e2e/test-case-catalog.yaml into this issue diff, and prepared compact worker artifacts without claiming final acceptance." +files_changed: + - {path: "docs/agents/e2e/test-case-catalog.yaml", summary: "Keeps the user-authorized deletion inside the issue #34 diff while leaving other unrelated dirty changes excluded from commit scope."} + - {path: "docs/agents/worker-results/issue-34.yaml", summary: "Stores the structured success result for issue #34."} + - {path: "docs/agents/handoffs/issue-34.yaml", summary: "Provides the compact verifier handoff for the issue #34 PR."} +test_case_repair: + applies: true + test_case_id: "E2E-004" + attempted_target_case: true + repaired_categories: ["none"] + failure_signature: "none" + assertion_changed: false + expected_outcome_changed: false +verification: + note: "Worker self-checks support implementation feedback only; final acceptance comes from the verifier evidence packet." + gates: {tdd_gate: "pass", implementation_self_check_gate: "pass", git_gate: "pass"} + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_publish.py tests/skill_scripts/test_dashboard_builder_e2e.py -q" + result: "pass" + evidence_ref: "local-worker:self-check-issue-34-regression-head" + summary: "Focused published-dashboard regression bucket passed 15/15, covering pre-revocation view-only render plus revoked-token denial with null dashboard/data payloads." + - command: "lsp_diagnostics on docs/agents/worker-results/issue-34.yaml and docs/agents/handoffs/issue-34.yaml" + result: "pass" + evidence_ref: "local-worker:self-check-issue-34-yaml-diagnostics" + summary: "Changed YAML worker artifacts are diagnostics-clean." + final_acceptance_claim: false +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-34.yaml" + verifier_packet: "" + raw_evidence_policy: "stored_outside_main_agent_context" +role_boundary: {actor_role: "issue_worker", may_execute_implementation_self_checks: true, may_execute_final_acceptance_qa: false, may_emit_final_verification: false, verifier_packet_required_for_completion: true} +pr: {number: "38", url: "https://github.com/paulpai0412/wferp/pull/38", ready_for_review: true} +blockers: ["Fresh verifier evidence is still required before issue #34 can be claimed as accepted."] +next_recommended_step: "Spawn a fresh verifier for the issue #34 PR to confirm E2E-004 and the focused dashboard-publish regression bucket." +handoff: {repo_path: "docs/agents/handoffs/issue-34.yaml", github_summary_posted: false} +metadata: {worker: "Sisyphus-issue_worker", worker_session_id: "worker:issue-34-20260511", completed_at: "2026-05-11T00:00:00+08:00"} diff --git a/docs/agents/worker-results/issue-6.yaml b/docs/agents/worker-results/issue-6.yaml new file mode 100644 index 0000000..48c8f7e --- /dev/null +++ b/docs/agents/worker-results/issue-6.yaml @@ -0,0 +1,76 @@ +schema_version: "1.0" +kind: worker_result +line_cap: 80 +raw_evidence_policy: index_only_refs_no_raw_logs_or_transcripts + +issue: + number: "6" + title: "Published Dashboard Link and view-only runtime" + url: "https://github.com/paulpai0412/wferp/issues/6" + +branch: "agent/issue-6-published-dashboard-link-runtime" +status: "success" + +summary: + objective: "Repair review findings for Published Dashboard Link runtime safety." + outcome: "Added public-interface coverage and surgical runtime handling so initial view data-source failures return a safe error state and malformed link expiration strings are denied without payload leaks or crashes." + +files_changed: + - path: "skill_scripts/dashboard_publish.py" + summary: "Catches view-time read failures as DASHBOARD_VIEW_FAILED and denies malformed expires_at values as LINK_EXPIRATION_MALFORMED before data-source access." + - path: "tests/skill_scripts/test_dashboard_publish.py" + summary: "Added TDD public-interface tests for safe initial view failure and malformed expires_at denial." + - path: "docs/agents/handoffs/issue-6.yaml" + summary: "Updated handoff to reflect PR #18, post-review fixes, and need for fresh verifier acceptance." + - path: "docs/agents/worker-results/issue-6.yaml" + summary: "Updated worker result packet for the repaired branch state." + - path: "docs/agents/evidence/issue-6.yaml" + summary: "Added PR #18 traceability while marking prior verifier evidence stale after new code changes." + +verification: + note: "Worker self-checks support implementation feedback only; final acceptance requires a fresh verifier packet because this worker changed code after the prior verifier evidence." + gates: + tdd_gate: "pass" + implementation_self_check_gate: "pass" + git_gate: "pass" + implementation_self_checks: + - command: "python3 -m pytest tests/skill_scripts/test_dashboard_publish.py -q" + result: "pass" + evidence_ref: "local-worker-repair:pytest-dashboard-publish" + summary: "Focused public-interface dashboard publish/runtime tests passed 13/13 after the two fixes." + - command: "python3 -m py_compile skill_scripts/dashboard_publish.py tests/skill_scripts/test_dashboard_publish.py" + result: "pass" + evidence_ref: "local-worker-repair:py-compile-dashboard-publish" + summary: "Changed Python files compiled successfully." + - command: "lsp_diagnostics changed Python files" + result: "pass" + evidence_ref: "local-worker-repair:lsp-dashboard-publish" + summary: "No diagnostics found for changed Python files." + final_acceptance_claim: false + +evidence_packet_refs: + worker_artifact_bundle: "docs/agents/worker-results/issue-6.yaml" + prior_verifier_packet: "docs/agents/evidence/issue-6.yaml" + prior_verifier_status: "stale_after_worker_repair" + required_fresh_verifier_packet: "pending fresh PR #18 verifier packet after repaired branch publication" + raw_evidence_policy: "stored_outside_main_agent_context" + +role_boundary: + actor_role: "issue_worker_repair" + may_execute_implementation_self_checks: true + may_execute_final_acceptance_qa: false + may_emit_final_verification: false + verifier_packet_required_for_completion: true + +pr: + number: "18" + url: "https://github.com/paulpai0412/wferp/pull/18" + ready_for_review: true + +blockers: ["Fresh verifier acceptance is required after these post-verifier code changes."] + +next_recommended_step: "Spawn a fresh PR #18 verifier to rerun issue #6 acceptance checks on the published repair branch state." + +handoff: + repo_path: "docs/agents/handoffs/issue-6.yaml" + github_summary_posted: true diff --git a/docs/prds/2026-05-06-workflow-erp-dashboard-builder.md b/docs/prds/2026-05-06-workflow-erp-dashboard-builder.md new file mode 100644 index 0000000..bc2c737 --- /dev/null +++ b/docs/prds/2026-05-06-workflow-erp-dashboard-builder.md @@ -0,0 +1,164 @@ +# PRD: Workflow ERP Dashboard Builder + +## Problem Statement + +Workflow ERP users can currently browse schema documentation and use the SQL tooling to generate SQL Server 2000-compatible, `SELECT`-only SQL from natural-language prompts. That solves the query-generation problem, but it does not give data analysts a complete path from a natural-language data question to a reviewed chart and a shareable Dashboard. + +Today, an analyst still needs to manually bridge several risky steps: confirm that generated SQL returns the intended data, decide how the result should be visualized, arrange multiple charts into a Dashboard, apply a consistent visual design, and publish a view-only link for consumers. Without a governed product flow, SQL correctness, chart correctness, visual consistency, refresh behavior, and published-link access are all easy to drift apart. + +## Solution + +Build a v1 Workflow ERP Dashboard Builder that sits above the existing NL-to-SQL and validation stack. + +The product lets a user enter a natural-language prompt, generate one validated `SELECT` SQL statement, execute it against a read-only replica, and present the result for Analyst Approval. After the SQL execution result is approved, the analyst creates one or more charts using form-based Chart Specs, sends those charts to a draggable/resizable Dashboard Canvas, chooses one LLM-presented Design Template derived from approved `DESIGN.md` patterns, and submits the Dashboard for final approval. Once approved, the Dashboard becomes a Published Dashboard with a token-accessible Published Dashboard Link. + +For v1, Published Dashboards query the read-only replica when viewed and may auto-refresh at a configured bounded frequency. They do not query the live production ERP database directly by default. + +## MVP Scope + +The MVP is the smallest end-to-end slice that proves a governed path from natural-language data request to Published Dashboard. + +- The analyst can submit a natural-language prompt and receive one validated SQL Server 2000-compatible `SELECT` statement. +- The SQL result can be executed against the read-only replica through the existing validation and execution boundary. +- The analyst can review concrete result evidence before approving the query result for chart creation. +- The analyst can create charts from approved query results using form-based Chart Specs. +- The MVP chart set includes table, KPI card, bar chart, and line chart. Additional chart types are deferred until the core flow is stable. +- The analyst can place and resize charts on a grid-based Dashboard Canvas. +- The analyst can choose one Design Template from a curated set of generic `DESIGN.md`-derived template options. +- The analyst or approver can preview the Dashboard and grant final Analyst Approval before publication. +- The system can publish an immutable Published Dashboard version with a token-accessible Published Dashboard Link. +- The Published Dashboard opens in view-only mode from a valid token link. +- The Published Dashboard can query the read-only replica on view and can auto-refresh within product-defined frequency bounds. +- The publisher can revoke a Published Dashboard Link. + +## Acceptance Criteria + +1. Given a supported natural-language prompt, when the analyst requests SQL generation, then the system produces exactly one SQL Server 2000-compatible `SELECT` statement or a clear refusal/error state. +2. Given generated SQL, when the system validates it, then non-`SELECT`, multi-statement, DDL/DML, stored procedure, hallucinated table/column, and SQL Server 2000-incompatible SQL are rejected before execution. +3. Given a valid generated SQL statement, when execution is requested, then the query runs only through the read-only execution boundary against the read-only replica. +4. Given a query execution result, when the analyst reviews it, then the UI shows result evidence including SQL, validation status, execution timestamp, returned columns, row count, sample rows, and configured aggregate checks when present. +5. Given an unapproved query result, when the analyst tries to create a chart, then chart creation is blocked. +6. Given an approved query result, when the analyst creates a Chart Spec, then the system accepts valid table, KPI card, bar chart, and line chart specs through forms and rejects invalid field mappings. +7. Given one or more valid Chart Specs, when the analyst adds them to the Dashboard Canvas, then each chart is placed in a grid cell layout that can be moved, resized, persisted, and replayed without overlap or out-of-bounds placement. +8. Given curated Design Templates, when the analyst asks for template options, then the LLM presents generic descriptive options and the Dashboard applies only one selected template at a time. +9. Given a Dashboard draft, when final Analyst Approval has not been granted, then the system cannot create or update a Published Dashboard from that draft. +10. Given a final-approved Dashboard version, when it is published, then the system creates an immutable Published Dashboard version and an opaque token Published Dashboard Link. +11. Given a valid, unexpired, unrevoked Published Dashboard Link, when a viewer opens it, then the Dashboard renders in view-only mode and does not expose authoring controls. +12. Given a revoked, expired, malformed, or unpublished token link, when a viewer opens it, then access is denied without exposing dashboard data. +13. Given a Published Dashboard with auto-refresh configured, when the refresh interval is below the product minimum or above allowed policy, then the system rejects the setting. +14. Given a Published Dashboard query refresh failure, when the viewer is on the Dashboard, then the UI shows a safe stale/error state instead of silently presenting stale data as current. +15. Given an existing Published Dashboard, when SQL, Dashboard Data Source, or query semantics change, then the system requires re-approval before those changes can be republished. +16. Given approval, publish, revoke, expire, token access denial, or refresh failure events, when they occur, then the system records auditable events. +17. Given the MVP is complete, when exercised end-to-end, then a user can go from prompt to validated SQL, approved result, chart creation, grid canvas layout, Design Template selection, final approval, token publication, token viewing, auto-refresh, and link revocation without bypassing validation gates. + +## User Stories + +1. As a data analyst, I want to enter a natural-language data prompt, so that I can start analysis without manually finding ERP tables and columns. +2. As a data analyst, I want the system to generate exactly one `SELECT` SQL statement, so that the query is constrained to a safe read-only shape. +3. As a data analyst, I want generated SQL to be checked against SQL Server 2000 compatibility, so that the query can run in the supported Workflow ERP environment. +4. As a data analyst, I want generated SQL to be checked against schema metadata, so that hallucinated tables and columns are rejected before execution. +5. As a data analyst, I want prompt/SQL consistency checks, so that obvious mismatches between my prompt and the generated SQL are caught. +6. As a data analyst, I want SQL execution to use a read-only replica, so that dashboard work does not directly load or risk the live production ERP database. +7. As a data analyst, I want to preview returned rows and columns, so that I can confirm the result matches the business question. +8. As a data analyst, I want to see result metadata such as row count and required columns, so that I can review data correctness before visualization. +9. As a data analyst, I want to approve a SQL execution result, so that only reviewed data can be used for chart creation. +10. As a data analyst, I want rejected query results to remain editable as drafts, so that I can revise the prompt or query path and try again. +11. As a data analyst, I want to create a Chart Spec from an approved query result, so that the chart is tied to reviewed data. +12. As a data analyst, I want to choose chart type through form controls, so that I do not need to write visualization-library JSON. +13. As a data analyst, I want to map x-axis, y-axis, and series fields through form controls, so that chart semantics are explicit. +14. As a data analyst, I want to set chart title, colors, sorting, and number/date formatting, so that the chart is readable and presentation-ready. +15. As a data analyst, I want invalid Chart Specs to be rejected before rendering, so that broken charts cannot enter a Dashboard. +16. As a data analyst, I want to send a chart to the Dashboard Canvas, so that I can compose a Dashboard from multiple approved charts. +17. As a data analyst, I want to repeat the prompt-to-chart flow, so that I can add multiple charts to the same Dashboard. +18. As a data analyst, I want to drag charts on a grid Dashboard Canvas, so that I can arrange Dashboard layout visually. +19. As a data analyst, I want to resize charts on the Dashboard Canvas, so that each chart can receive the right amount of space. +20. As a data analyst, I want grid layout changes to persist, so that the Published Dashboard matches the reviewed arrangement. +21. As a data analyst, I want the layout system to prevent invalid grid states, so that charts do not overlap or render outside the Dashboard Canvas. +22. As a data analyst, I want the LLM to batch-present Design Template options, so that I can choose a visual style quickly. +23. As a data analyst, I want Design Templates to use generic descriptive names, so that the product does not present copied brand names as official themes. +24. As a data analyst, I want only one selected Design Template applied at a time, so that Dashboard styling remains predictable. +25. As a data analyst, I want to preview the Dashboard before publishing, so that I can check charts, layout, and template together. +26. As an approver, I want final Analyst Approval before publishing, so that only reviewed Dashboard versions become Published Dashboards. +27. As an approver, I want to review the SQL result evidence, Chart Specs, layout, template, and refresh policy before publishing, so that approval covers the complete Dashboard behavior. +28. As a data analyst, I want a Published Dashboard to be versioned, so that published content is not silently changed by editing a draft. +29. As a data analyst, I want SQL or data-source changes to require re-approval, so that published analytics do not drift from reviewed data. +30. As a data analyst, I want visual-only draft edits to be recorded, so that Dashboard changes remain auditable. +31. As a publisher, I want to create a Published Dashboard Link, so that viewers can access the approved Dashboard through a standalone URL. +32. As a publisher, I want the Published Dashboard Link to use an unguessable token, so that access is not based on predictable URLs. +33. As a publisher, I want a Published Dashboard Link to be revocable, so that shared access can be removed when needed. +34. As a publisher, I want a Published Dashboard Link to support expiration, so that access can be time-bounded. +35. As a viewer, I want a valid token link to open a read-only Published Dashboard, so that I can consume analytics without changing them. +36. As a viewer, I want revoked, expired, malformed, or unpublished links to be denied, so that unavailable Dashboards are not exposed. +37. As a viewer, I want clear stale-data or refresh-error indicators, so that I do not mistake old data for current data. +38. As a data analyst, I want to configure Dashboard auto-refresh frequency within product-defined bounds, so that viewers can see updated data without overloading the replica. +39. As a system operator, I want refresh frequency limits, query timeouts, and row limits, so that Published Dashboards cannot exhaust the read-only replica. +40. As a system operator, I want audit events for approval, publishing, link revocation, expiration, and refresh failure, so that sensitive ERP-derived analytics can be traced. +41. As a maintainer, I want the Dashboard Builder to reuse the existing SQL safety and validation stack, so that prompt-to-SQL behavior remains consistent with current Workflow ERP tooling. +42. As a maintainer, I want Dashboard-specific behavior to live above the SQL tooling layer, so that chart, canvas, and publishing concerns do not pollute the core SQL generator. + +## Implementation Decisions + +- The Dashboard Builder is a new product layer above the existing Workflow ERP SQL-generation stack. It reuses the current prompt-to-SQL, SQL policy validation, metadata validation, prompt/SQL consistency, execution validation, and database-client concepts rather than replacing them. +- v1 uses the read-only replica as the Dashboard Data Source. Published Dashboards do not directly query live production ERP by default. +- Generated SQL must remain one SQL Server 2000-compatible `SELECT` statement. Non-`SELECT` intent, multi-statement SQL, DDL/DML, stored procedure execution, and SQL Server 2000-incompatible constructs remain forbidden. +- Query execution for the Dashboard Builder must go through a read-only execution boundary with health checks, query timeout handling, row limits, and clear failure states. +- Analyst Approval is required after SQL execution result review before chart creation, and again before a Dashboard version becomes a Published Dashboard. +- Publishing creates or updates an approved Published Dashboard version. A Published Dashboard must not be a live mutable draft. +- SQL, Dashboard Data Source, or query-semantics changes require re-approval before republishing. Visual-only changes should be recorded and may follow a lighter review policy, but they must not silently alter data meaning. +- A Published Dashboard Link uses an opaque, non-guessable token scoped to one Published Dashboard. Tokens must be revocable and should support expiration. +- Published Dashboard access is view-only. Token access does not imply authoring permissions. +- Published Dashboards query the read-only replica when viewed and may poll at a configured auto-refresh frequency. Refresh frequency must be bounded by product limits. +- The v1 refresh model is view-time query plus optional bounded polling. A separate background scheduling system is not required unless a later phase adds cached refresh jobs. +- Chart Specs are edited through forms. The v1 interface supports chart type, field mapping, sorting, title, color, and number/date formatting. Users do not directly edit the underlying visualization-library JSON. +- Chart Spec validation is server-side and chart-type aware. Invalid mappings or unsupported field combinations cannot be published. +- The Dashboard Canvas uses a draggable and resizable grid layout. Layout persistence stores grid coordinates and dimensions for each chart and validates bounds and collisions. +- Design Templates are copied or adapted from the MIT-licensed `VoltAgent/awesome-design-md` collection, renamed into generic descriptive theme names, and presented by the LLM for user selection. +- Only one selected Design Template is applied to a Dashboard at a time. The product should not blend multiple templates in v1. +- The PRD expects several deep modules with stable, testable interfaces: + - SQL Request Orchestrator: prompt-to-SQL generation, validation, execution request, and result evidence capture. + - Approval Workflow State Machine: draft, result-reviewed, approved, published, revoked, and expired lifecycle transitions. + - Published Link Policy: token issuance, validation, expiration, revocation, and view-only enforcement. + - Refresh Policy Evaluator: refresh interval bounds, disabled auto-refresh, view-time refresh, and safe failure states. + - Chart Spec Validator: chart-type-specific field mapping and formatting validation. + - Dashboard Canvas Layout Engine: grid placement, resize, collision rules, bounds validation, and serialization. + - Design Template Catalog: curated `DESIGN.md` template ingestion, generic naming, LLM presentation, and selected-template application. + - Published Dashboard Runtime: render a published version, resolve token access, run bounded queries, and present refresh states. +- Audit events should capture query result approval, publish, revoke, expire, token access denial, and query refresh failure. + +## Testing Decisions + +- Tests should verify external behavior and state transitions, not implementation details. Pure modules should be tested with fake dependencies where possible. +- SQL safety tests should cover unsafe SQL, schema-invalid SQL, prompt mismatches, SQL Server 2000-incompatible syntax, and non-`SELECT` intent. +- SQL execution integration tests should use the existing test database pattern and verify read-only execution, expected columns, row-count bounds, and aggregate checks. +- Approval Workflow State Machine tests should cover draft to result-reviewed, approved, published, revoked, expired, rejected, and invalid transition paths. +- Published Link Policy tests should cover valid token, malformed token, expired token, revoked token, unpublished Dashboard, and view-only behavior. +- Refresh Policy Evaluator tests should cover disabled refresh, allowed frequency, too-frequent intervals, view-time query, query timeout, and refresh failure stale/error output. +- Chart Spec Validator tests should cover valid and invalid specs for each supported v1 chart type, including missing fields, unsupported dimensions, invalid formatting, and field type mismatches. +- Dashboard Canvas Layout Engine tests should cover add, move, resize, overlap rejection, bounds rejection, serialization, and persisted layout replay. +- Design Template Catalog tests should verify generic naming, allowed template selection, one-template-at-a-time application, and deterministic presentation payloads. +- Published Dashboard Runtime integration tests should cover token-access view, read-only rendering, bounded refresh, data-source failure, and revocation denial. +- Browser-level tests are required when a web UI exists, especially for chart creation, grid dragging/resizing, template selection, and Published Dashboard rendering. +- Snapshot tests for generated Design Template output should only be used if template output is deterministic enough to remain stable. + +## Out of Scope + +- Writeback dashboards, ERP updates, inserts, deletes, DDL, stored procedures, or arbitrary production DB execution. +- Arbitrary SQL execution that bypasses the existing SQL safety and validation stack. +- Arbitrary custom JavaScript, arbitrary custom CSS, or direct free-form chart JSON editing. +- Live production ERP queries as the default v1 Dashboard Data Source. +- Real-time streaming dashboards or sub-second refresh. +- Full BI semantic layer, metric catalog, lineage browser, or advanced drill-through system. +- Full role-based access control or row-level security beyond token revocation and expiration. +- Multi-template composition or user-uploaded unvetted design systems. +- Collaborative real-time Dashboard editing. +- Email delivery, scheduled exports, alerting, dashboard marketplace, or external embedding SDK. +- Replacing the legacy schema publication workflow or changing static schema documentation generation. + +## Further Notes + +- The existing repo already defines SQL correctness operationally: SQL is not correct merely because it parses; execution result shape and values must match the prompt intent. +- Token-accessible Published Dashboard Links are convenient but weaker than authenticated access. v1 must treat token leakage as a real risk and include revocation, expiration, and audit logging. +- Read-only replicas reduce production ERP risk but can still be overloaded by expensive reads. v1 must include refresh frequency bounds, query timeouts, and row limits. +- Analyst Approval should capture concrete evidence: prompt, generated SQL, validation results, execution timestamp, returned columns, sample rows, row counts, and aggregate checks when applicable. +- Design Templates from `VoltAgent/awesome-design-md` are MIT licensed, but v1 should use generic descriptive names and avoid copying protected brand assets or presenting brand names as official Dashboard themes. +- This PRD is published as a GitHub issue with `needs-triage` and is also stored locally so future issue breakdown can reference the same source text. diff --git a/docs/prds/2026-05-09-sql-to-dashboard-e2e-test-cases.md b/docs/prds/2026-05-09-sql-to-dashboard-e2e-test-cases.md new file mode 100644 index 0000000..f9e01ac --- /dev/null +++ b/docs/prds/2026-05-09-sql-to-dashboard-e2e-test-cases.md @@ -0,0 +1,126 @@ +# PRD: SQL-to-Dashboard E2E Test Cases + +## Problem Statement + +Workflow ERP now has a CLI/library Dashboard Builder path that can move from a natural-language prompt to a Published Dashboard, but the current E2E coverage is still mostly library-driven and uses limited deterministic data. Maintainers need executable test case intent that proves the SQL-to-Dashboard path against real SQL execution evidence, deterministic test data, Analyst Approval gates, Chart Spec creation, Dashboard Canvas publication, and Published Dashboard Link denial behavior. + +The product also has a future browser-level requirement, but the repository does not yet contain a Dashboard Web UI that Playwright can operate. The immediate problem is therefore to materialize CLI/library E2E test cases that are honest about the current surface while preserving a clear path to browser Playwright tests once a Web UI exists. + +## Solution + +Create a focused SQL-to-Dashboard E2E test-case PRD for the current CLI/library surface. The test cases will use the existing Dashboard Builder modules, the existing SQL Server test database pattern, live LLM SQL generation as non-blocking evidence, and deterministic AI-authored SQL fixture data committed to the repository. + +The first business scenario is `查詢2026年的工程預算明細`. It will extend the existing ACTMK budget-detail fixture shape to include three 2026 rows and one 2025 contrast row. SQL correctness will be judged by execution result shape and values, not exact SQL string equality. The expected 2026 aggregate is `sum(MK006)=450000`. + +The first batch contains four executable test intents: + +1. Happy path from prompt to Published Dashboard. +2. SQL result shape/value correctness against the real test database. +3. Query Analyst Approval gate before Chart Spec creation. +4. Revoked Published Dashboard Link denial without dashboard data exposure. + +Browser Playwright E2E remains deferred until a real Dashboard Web UI exists. + +## User Stories + +1. As a data analyst, I want the 2026 engineering budget prompt to execute against deterministic test data, so that SQL result evidence can be reviewed with confidence. +2. As a data analyst, I want SQL correctness to be judged by returned columns, row count, sample rows, and aggregate checks, so that a query is not accepted merely because it parses. +3. As a data analyst, I want the 2025 contrast budget row excluded from the 2026 prompt result, so that prompt/year semantics are verified. +4. As a data analyst, I want the query result to require Analyst Approval before chart creation, so that unreviewed data cannot enter a Dashboard. +5. As a data analyst, I want an approved query result to produce a bar Chart Spec using `MK006`, so that budget values can be visualized consistently. +6. As a data analyst, I want the Chart Spec to be placed on a Dashboard Canvas, so that the SQL result can flow into the current Dashboard Builder surface. +7. As an approver, I want final Analyst Approval before publishing, so that only reviewed Dashboard versions become Published Dashboards. +8. As a publisher, I want the Published Dashboard Link to render view-only output before revocation, so that consumers can safely view approved analytics. +9. As a publisher, I want a revoked Published Dashboard Link to return a denial state, so that shared access can be removed. +10. As a security reviewer, I want revoked-link denial to expose no dashboard payload and no data rows, so that ERP-derived analytics do not leak after revocation. +11. As a maintainer, I want live LLM E2E runs to produce evidence without blocking release by default, so that model availability does not create false product failures. +12. As a maintainer, I want deterministic regression coverage beside live LLM evidence, so that product behavior can still be gated reliably. +13. As a maintainer, I want AI-generated test data committed as a fixed SQL fixture, so that test results are reproducible and reviewable. +14. As a maintainer, I want one issue per executable E2E test case, so that workers and verifiers can repair and validate cases independently. +15. As a future UI implementer, I want browser Playwright E2E marked as deferred until a Dashboard Web UI exists, so that tests do not pretend to cover a non-existent browser surface. + +## Implementation Decisions + +- The current implementation target is the CLI/library surface, not a browser UI. +- Browser Playwright tests are out of the first implementation batch because no Dashboard Web UI currently exists in the repository. +- The primary business prompt is `查詢2026年的工程預算明細`. +- The primary ERP table remains ACTMK because the current SQL tooling, test database, and Dashboard Builder E2E already use ACTMK budget-detail semantics. +- Test data will be generated with AI assistance during development, reviewed, and committed as deterministic SQL fixture data. Tests must not call AI to create fixture rows at runtime. +- The fixture data shape is three 2026 ACTMK budget rows plus one 2025 contrast row. +- The expected aggregate for 2026 rows is `sum(MK006)=450000`. +- SQL correctness is defined by execution result shape and values: returned columns, row count, sample rows, and aggregate checks. +- Exact SQL string equality is not the primary correctness assertion. SQL may be inspected for diagnostics, but passing depends on result evidence. +- Live LLM generation is part of the first-batch E2E evidence, but it is non-blocking by default because model/network availability can fail independently of product behavior. +- A deterministic regression bucket must accompany live LLM evidence and should remain suitable for release gating. +- The first Chart Spec is a bar chart using `MK006` as the budget value field. +- The first batch should be split into four E2E test case issues after this PRD is accepted. +- Existing domain terms must be preserved: Analyst Approval, Chart Spec, Dashboard Canvas, Published Dashboard, Published Dashboard Link, and Dashboard Data Source. + +## Testing Decisions + +- Tests should verify external behavior and state transitions rather than implementation details. +- The SQL correctness test must execute against the SQL Server test database in `DB_ENV=test`. +- The SQL correctness test must assert required returned columns including `MK002` and `MK006`. +- The SQL correctness test must assert the 2026 result row count is three. +- The SQL correctness test must assert `sum(MK006)=450000` for the 2026 result set. +- The SQL correctness test must ensure the 2025 contrast row does not satisfy the 2026 prompt intent. +- The approval gate test must prove that Chart Spec creation fails before query result Analyst Approval and succeeds after approval. +- The Dashboard flow test must prove the approved query result can become a bar Chart Spec, enter a Dashboard Canvas, receive final Analyst Approval, become a Published Dashboard, and render through a Published Dashboard Link. +- The link-denial test must prove revoked token access returns a denial state with no dashboard payload and no data rows. +- Live LLM test evidence must classify failures as product, test data seed, infra, or LLM availability before any release decision treats the failure as blocking. +- Deterministic regression tests should continue to cover the existing governed query, Chart Spec, Dashboard Canvas, Design Template Catalog, and Published Dashboard runtime surfaces. +- Evidence packets must stay compact and reference artifact manifests rather than embedding raw SQL logs, raw Playwright traces, screenshots, or verbose command output. +- Browser Playwright cases should be represented only as deferred intent until a real Dashboard Web UI route, server startup command, and stable selectors exist. + +### E2E Test Intents + +```yaml +e2e_test_intents: + - test_case_id: "E2E-001" + source_ac: "Dashboard Builder AC17" + scenario: "Prompt-to-Published-Dashboard happy path for 2026 engineering budget." + surface: "cli|library" + expected_outcome: "Live LLM governed query returns execution evidence, Analyst Approval unlocks bar Chart Spec creation, Dashboard Canvas preview publishes, and a Published Dashboard Link renders view-only." + risk_level: "high" + executable_status: "draft" + - test_case_id: "E2E-002" + source_ac: "SQL execution correctness" + scenario: "2026 engineering budget SQL executes against deterministic test data with expected columns, row count, sample rows, and aggregate value." + surface: "sql|cli|library" + expected_outcome: "Returned columns include MK002 and MK006, row_count is 3 for 2026 rows, and sum(MK006)=450000 while the 2025 contrast row is excluded." + risk_level: "high" + executable_status: "draft" + - test_case_id: "E2E-003" + source_ac: "Dashboard Builder AC5" + scenario: "Unapproved query result cannot create a Chart Spec." + surface: "library" + expected_outcome: "Chart Spec creation fails with a query-approval gate before Analyst Approval and succeeds after approval." + risk_level: "medium" + executable_status: "draft" + - test_case_id: "E2E-004" + source_ac: "Dashboard Builder AC12" + scenario: "Revoked Published Dashboard Link denies access without exposing dashboard data." + surface: "library" + expected_outcome: "Valid link renders before revocation; revoked link returns denied with no dashboard payload and no data rows." + risk_level: "medium" + executable_status: "draft" +``` + +## Out of Scope + +- Implementing a Dashboard Web UI. +- Writing browser Playwright tests before a Dashboard Web UI exists. +- Treating live LLM E2E failure as an automatic release blocker. +- Runtime AI generation of test data. +- Exact SQL string equality as the primary correctness assertion. +- Production database execution or any non-test Dashboard Data Source. +- DML, DDL, stored procedures, writeback dashboards, or arbitrary SQL execution. +- Replacing the existing Dashboard Builder PRD. +- Replacing the legacy static schema documentation workflow. + +## Further Notes + +- This PRD supplements the existing Workflow ERP Dashboard Builder PRD by focusing on executable E2E test-case materialization. +- The current repository has a CLI/library Dashboard Builder surface and static schema documentation, but no Dashboard Web UI. Future browser Playwright E2E should be introduced only after a real UI route, startup command, and stable selectors exist. +- SQL Server 2019 test containers cannot truly emulate SQL Server 2000 compatibility level 80. The repo should continue using explicit SQL Server 2000 syntax guards and deterministic execution checks. +- The next step after this PRD is accepted is to use `to-issues` to create independently grabbable issues for the fixture, SQL correctness case, Dashboard flow case, approval gate case, and link denial case. diff --git a/docs/superpowers/plans/2026-04-21-llm-sql-generation-mode.md b/docs/superpowers/plans/2026-04-21-llm-sql-generation-mode.md new file mode 100644 index 0000000..82a1e90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-llm-sql-generation-mode.md @@ -0,0 +1,680 @@ +# LLM SQL Generation Mode (Dual-Track) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an LLM-first SQL generation path that still guarantees SQL Server 2000 + SELECT-only output by validating and falling back to deterministic rules when needed. + +**Architecture:** Keep existing deterministic generator as a safety fallback. Add focused modules for schema context slicing, LLM provider adapter + envelope parser, metadata validation, prompt-to-SQL consistency validation, and a dual-track router. Rollout starts with `shadow` mode (returns rule SQL while logging LLM comparison), then advances to `llm-first`. + +**Tech Stack:** Python 3.12, existing `skill_scripts/*`, pytest, JSON artifacts under `skill_scripts/artifacts`. + +--- + +## File Structure (planned) + +- Create: `skill_scripts/schema_context_builder.py` +- Create: `skill_scripts/llm_sql_generator.py` +- Create: `skill_scripts/metadata_validator.py` +- Create: `skill_scripts/prompt_sql_consistency.py` +- Create: `skill_scripts/database_client.py` +- Create: `skill_scripts/execution_validator.py` +- Create: `skill_scripts/sql_router.py` +- Modify: `skill_scripts/cli_generate_select.py` +- Modify: `skill_scripts/sql_generator.py` (export helper for deterministic fallback) +- Modify: `skills/workflow-erp-sql-generator/SKILL.md` +- Create: `tests/skill_scripts/test_schema_context_builder.py` +- Create: `tests/skill_scripts/test_llm_sql_generator.py` +- Create: `tests/skill_scripts/test_metadata_validator.py` +- Create: `tests/skill_scripts/test_prompt_sql_consistency.py` +- Create: `tests/skill_scripts/test_database_client.py` +- Create: `tests/skill_scripts/test_execution_validator.py` +- Create: `tests/skill_scripts/test_sql_router.py` +- Modify: `tests/skill_scripts/test_sql_generator.py` + +### Task 1: Add schema context builder for LLM prompt slicing + +**Files:** +- Create: `skill_scripts/schema_context_builder.py` +- Test: `tests/skill_scripts/test_schema_context_builder.py` + +- [ ] **Step 1: Write failing tests for context slicing** + +```python +from skill_scripts.schema_context_builder import build_context_slice + + +def test_build_context_slice_returns_topk_tables_and_columns(): + bundle = { + "tables": [ + {"TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "ModuleID": "ACT"}, + {"TableID": "ACTMJ", "TableName": "科目/部門預算單頭檔", "ModuleID": "ACT"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度"}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算"}, + ], + } + ctx = build_context_slice("查詢2026年的工程預算明細", bundle, top_k=2) + assert "tables" in ctx + assert "columns" in ctx + assert any(t["TableID"] == "ACTMK" for t in ctx["tables"]) +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_schema_context_builder.py -v` +Expected: FAIL with module/function missing + +- [ ] **Step 3: Implement minimal context builder** + +```python +# skill_scripts/schema_context_builder.py +from typing import Any + + +JsonDict = dict[str, Any] + + +def build_context_slice(prompt: str, bundle: JsonDict, top_k: int = 8) -> JsonDict: + prompt_text = str(prompt or "") + tables = bundle.get("tables", []) + fields = bundle.get("fields", []) + + def score(table: JsonDict) -> int: + table_id = str(table.get("TableID", "")) + table_name = str(table.get("TableName", "")) + s = 0 + if table_id and table_id in prompt_text: + s += 100 + if table_name and table_name in prompt_text: + s += 80 + if "預算" in prompt_text and table_id in {"ACTMJ", "ACTMK", "ACTMI"}: + s += 70 + if "明細" in prompt_text and table_id == "ACTMK": + s += 80 + return s + + ranked = sorted(tables, key=score, reverse=True)[: max(1, top_k)] + picked_ids = {str(t.get("TableID", "")) for t in ranked} + picked_fields = [f for f in fields if str(f.get("TableID", "")) in picked_ids] + + return {"tables": ranked, "columns": picked_fields} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `pytest tests/skill_scripts/test_schema_context_builder.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/schema_context_builder.py tests/skill_scripts/test_schema_context_builder.py +git commit -m "feat: add schema context slicing for llm sql generation" +``` + +### Task 2: Add LLM provider adapter and deterministic envelope parsing + +**Files:** +- Create: `skill_scripts/llm_sql_generator.py` +- Test: `tests/skill_scripts/test_llm_sql_generator.py` + +- [ ] **Step 1: Write failing tests for structured envelope parsing** + +```python +from skill_scripts.llm_sql_generator import parse_llm_response + + +def test_parse_llm_response_extracts_sql_and_tables(): + payload = '{"sql":"SELECT TOP 10 * FROM [VPIC1].[dbo].[ACTMK]","used_tables":["ACTMK"],"assumptions":["year filter"],"confidence":0.82}' + out = parse_llm_response(payload) + assert out["sql"].startswith("SELECT") + assert out["used_tables"] == ["ACTMK"] + + +def test_parse_llm_response_includes_confidence_range(): + payload = '{"sql":"SELECT TOP 10 * FROM [VPIC1].[dbo].[ACTMK]","used_tables":["ACTMK"],"assumptions":[],"confidence":0.42}' + out = parse_llm_response(payload) + assert 0.0 <= out["confidence"] <= 1.0 +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_llm_sql_generator.py -v` +Expected: FAIL with missing implementation + +- [ ] **Step 3: Implement minimal LLM wrapper and parser** + +```python +# skill_scripts/llm_sql_generator.py +import json +from typing import Any + + +JsonDict = dict[str, Any] + + +def build_llm_prompt(user_prompt: str, context_slice: JsonDict) -> str: + return ( + "Generate one SQL Server 2000 SELECT statement only. " + "Return JSON with keys sql, used_tables, assumptions, confidence. " + f"User prompt: {user_prompt}\n" + f"Context: {json.dumps(context_slice, ensure_ascii=False)}" + ) + + +def parse_llm_response(raw_text: str) -> JsonDict: + obj = json.loads(raw_text) + return { + "sql": str(obj.get("sql", "")).strip(), + "used_tables": [str(x) for x in obj.get("used_tables", [])], + "assumptions": [str(x) for x in obj.get("assumptions", [])], + "confidence": float(obj.get("confidence", 0.0)), + } + + +def call_llm(provider: str, model: str, prompt_text: str) -> str: + # provider adapter contract for implementation phase + # 1) send prompt_text to provider/model + # 2) return raw text response from model + # this function is intentionally a dedicated seam for production integration + raise RuntimeError("LLM_PROVIDER_NOT_CONFIGURED") +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `pytest tests/skill_scripts/test_llm_sql_generator.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/llm_sql_generator.py tests/skill_scripts/test_llm_sql_generator.py +git commit -m "feat: add llm sql envelope parser and prompt builder" +``` + +### Task 3: Add metadata validator for table/column existence + +**Files:** +- Create: `skill_scripts/metadata_validator.py` +- Test: `tests/skill_scripts/test_metadata_validator.py` + +- [ ] **Step 1: Write failing tests for metadata checks** + +```python +from skill_scripts.metadata_validator import validate_metadata_references + + +def test_validate_metadata_references_rejects_unknown_table(): + bundle = { + "tables": [{"TableID": "ACTMK"}], + "fields": [{"TableID": "ACTMK", "ID": "MK002"}], + } + ok, code = validate_metadata_references( + "SELECT [MK002] FROM [VPIC1].[dbo].[ACTXX]", + bundle, + ) + assert ok is False + assert code == "UNKNOWN_TABLE" +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_metadata_validator.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement validator** + +```python +# skill_scripts/metadata_validator.py +import re +from typing import Any + + +JsonDict = dict[str, Any] + + +def validate_metadata_references(sql: str, bundle: JsonDict) -> tuple[bool, str]: + table_ids = {str(t.get("TableID", "")).strip().upper() for t in bundle.get("tables", [])} + field_ids = {str(f.get("ID", "")).strip().upper() for f in bundle.get("fields", [])} + + table_refs = { + m.group(1).upper() + for m in re.finditer(r"\b(?:FROM|JOIN)\s+\[[^\]]+\]\.\[[^\]]+\]\.\[([^\]]+)\]", sql, re.IGNORECASE) + } + for table in table_refs: + if table not in table_ids: + return False, "UNKNOWN_TABLE" + + col_tokens = {m.group(1).upper() for m in re.finditer(r"\[([A-Za-z]{2}\d{3})\]", sql)} + if any(c not in field_ids for c in col_tokens): + return False, "UNKNOWN_COLUMN" + + return True, "OK" +``` + +- [ ] **Step 4: Run tests** + +Run: `pytest tests/skill_scripts/test_metadata_validator.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/metadata_validator.py tests/skill_scripts/test_metadata_validator.py +git commit -m "feat: add metadata reference validator for llm sql output" +``` + +### Task 4: Add SQL router (shadow + llm-first with confidence and consistency gates) + +**Files:** +- Create: `skill_scripts/sql_router.py` +- Modify: `skill_scripts/sql_generator.py` +- Test: `tests/skill_scripts/test_sql_router.py` + +- [ ] **Step 1: Write failing router tests** + +```python +from skill_scripts.sql_router import route_generate_sql + + +def test_route_generate_sql_falls_back_when_llm_sql_invalid(monkeypatch): + bundle = {"tables": [], "fields": [], "modules": [], "index_keys": []} + + def fake_llm(*args, **kwargs): + return {"sql": "DELETE FROM ACTMK", "used_tables": [], "assumptions": [], "confidence": 0.9} + + sql, meta = route_generate_sql("查詢預算", bundle, llm_generate=fake_llm) + assert sql.startswith("SELECT") + assert meta["route"] == "fallback_rule" +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_sql_router.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement router** + +```python +# skill_scripts/sql_router.py +from typing import Any, Callable + +from skill_scripts.schema_context_builder import build_context_slice +from skill_scripts.intent_parser import parse_intent +from skill_scripts.llm_sql_generator import build_llm_prompt, call_llm, parse_llm_response +from skill_scripts.sql2000_guard import validate_sql +from skill_scripts.sql_generator import generate_select_sql +from skill_scripts.metadata_validator import validate_metadata_references +from skill_scripts.prompt_sql_consistency import validate_prompt_sql_consistency + + +JsonDict = dict[str, Any] + + +def route_generate_sql( + prompt: str, + bundle: JsonDict, + mode: str = "rule", + provider: str = "none", + model: str = "none", + min_confidence: float = 0.6, +) -> tuple[str, JsonDict]: + intent = parse_intent(prompt) + if intent.get("non_select_intent"): + raise ValueError("NON_SELECT_INTENT") + + if mode == "rule": + return generate_select_sql(prompt, bundle), {"route": "rule", "reason": "RULE_MODE"} + + context = build_context_slice(prompt, bundle) + llm_prompt = build_llm_prompt(prompt, context) + raw = call_llm(provider, model, llm_prompt) + llm_out = parse_llm_response(raw) + llm_sql = str(llm_out.get("sql", "")).strip() + + if float(llm_out.get("confidence", 0.0)) < min_confidence: + fallback = generate_select_sql(prompt, bundle) + if mode == "shadow": + return fallback, {"route": "shadow_rule", "reason": "LOW_CONFIDENCE", "candidate_sql": llm_sql} + return fallback, {"route": "fallback_rule", "reason": "LOW_CONFIDENCE"} + + ok_policy, code_policy = validate_sql(llm_sql) + if ok_policy: + ok_meta, code_meta = validate_metadata_references(llm_sql, bundle) + ok_consistency, code_consistency = validate_prompt_sql_consistency(prompt, llm_sql) + if ok_meta and ok_consistency: + if mode == "shadow": + fallback = generate_select_sql(prompt, bundle) + return fallback, {"route": "shadow_rule", "reason": "SHADOW_COMPARE", "candidate_sql": llm_sql} + return llm_sql, {"route": "llm", "reason": "OK"} + reason = code_meta if not ok_meta else code_consistency + return generate_select_sql(prompt, bundle), {"route": "fallback_rule", "reason": reason} + return generate_select_sql(prompt, bundle), {"route": "fallback_rule", "reason": code_policy} +``` + +- [ ] **Step 4: Run tests** + +Run: `pytest tests/skill_scripts/test_sql_router.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/sql_router.py tests/skill_scripts/test_sql_router.py skill_scripts/sql_generator.py +git commit -m "feat: add llm-first sql router with deterministic fallback" +``` + +### Task 5: Integrate CLI `rule` / `shadow` / `llm-first` modes and telemetry + +**Files:** +- Modify: `skill_scripts/cli_generate_select.py` +- Modify: `skills/workflow-erp-sql-generator/SKILL.md` +- Test: `tests/skill_scripts/test_sql_generator.py` + +- [ ] **Step 1: Write failing CLI route test** + +```python +import sys +from skill_scripts.cli_generate_select import main + + +def test_cli_supports_llm_mode_flag(capsys): + old_argv = sys.argv + sys.argv = [ + "cli_generate_select", + "--mode", "llm-first", + "--prompt", "查詢2026年的工程預算明細", + ] + try: + main() + finally: + sys.argv = old_argv + + captured = capsys.readouterr() + assert "SELECT" in captured.out + assert "ROUTE:" in captured.out +``` + +- [ ] **Step 2: Implement CLI flags and route output** + +```python +# add args +parser.add_argument("--mode", choices=["rule", "shadow", "llm-first"], default="rule") +parser.add_argument("--llm-provider", default="none") +parser.add_argument("--llm-model", default="none") +parser.add_argument("--llm-min-confidence", type=float, default=0.6) + +# output +print(sql) +print(f"ROUTE:{meta['route']} REASON:{meta['reason']}") +if "candidate_sql" in meta: + print(f"CANDIDATE_SQL:{meta['candidate_sql']}") +``` + +- [ ] **Step 3: Update SKILL contract doc** + +```markdown +- Mode `rule`: deterministic generation only +- Mode `shadow`: generate LLM candidate, return rule SQL, emit candidate SQL for comparison +- Mode `llm-first`: attempt LLM generation, then hard-validate, fallback to rule on any failure +- Every response emits route metadata (`rule`, `shadow_rule`, `llm`, `fallback_rule`) +``` + +- [ ] **Step 4: Run targeted tests** + +Run: `pytest tests/skill_scripts/test_sql_generator.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/cli_generate_select.py skills/workflow-erp-sql-generator/SKILL.md tests/skill_scripts/test_sql_generator.py +git commit -m "feat: add dual-track cli mode and route telemetry" +``` + +### Task 6: Add prompt-to-SQL consistency validator + +**Files:** +- Create: `skill_scripts/prompt_sql_consistency.py` +- Create: `tests/skill_scripts/test_prompt_sql_consistency.py` + +- [ ] **Step 1: Write failing consistency tests** + +```python +from skill_scripts.prompt_sql_consistency import validate_prompt_sql_consistency + + +def test_validate_prompt_sql_consistency_detects_year_mismatch(): + ok, code = validate_prompt_sql_consistency( + "查詢2026年的工程預算明細", + "SELECT * FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2025'", + ) + assert ok is False + assert code == "YEAR_MISMATCH" +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_prompt_sql_consistency.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement consistency validator** + +```python +# skill_scripts/prompt_sql_consistency.py +import re + + +def validate_prompt_sql_consistency(prompt: str, sql: str) -> tuple[bool, str]: + text = str(prompt or "") + query = str(sql or "") + + m_year = re.search(r"(? "DatabaseConfig": + return DatabaseConfig( + driver=os.getenv("DB_DRIVER", "mssql"), + connection_string=os.getenv("DB_CONNECTION_STRING", ""), + auth_mode=os.getenv("DB_AUTH_MODE", "sql_auth"), + env=os.getenv("DB_ENV", "test"), + ) + + +class DatabaseClient: + def __init__(self, config: DatabaseConfig): + self.config = config + + def health_check(self) -> tuple[bool, str]: + if not self.config.connection_string: + return False, "DB_CONNECTION_NOT_CONFIGURED" + return True, "OK" + + def execute_readonly(self, sql: str): + # implementation adapter in execution phase (pymssql/pyodbc) + raise RuntimeError("DB_EXECUTION_NOT_IMPLEMENTED") +``` + +- [ ] **Step 4: Write failing execution validator tests** + +```python +from skill_scripts.execution_validator import validate_execution_result + + +def test_validate_execution_result_checks_required_columns(): + rows = [{"MK002": "2026", "MK006": 1000}] + ok, code = validate_execution_result( + rows, + required_columns=["MK002", "MK006"], + min_rows=1, + ) + assert ok is True + assert code == "OK" +``` + +- [ ] **Step 5: Implement execution validator** + +```python +# skill_scripts/execution_validator.py +def validate_execution_result(rows, required_columns, min_rows=0, max_rows=None): + if len(rows) < min_rows: + return False, "ROWCOUNT_TOO_LOW" + if max_rows is not None and len(rows) > max_rows: + return False, "ROWCOUNT_TOO_HIGH" + if rows: + first_keys = set(rows[0].keys()) + if any(col not in first_keys for col in required_columns): + return False, "MISSING_REQUIRED_COLUMN" + return True, "OK" +``` + +- [ ] **Step 6: Run tests to verify pass** + +Run: `pytest tests/skill_scripts/test_database_client.py tests/skill_scripts/test_execution_validator.py -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add skill_scripts/database_client.py skill_scripts/execution_validator.py tests/skill_scripts/test_database_client.py tests/skill_scripts/test_execution_validator.py +git commit -m "feat: add configurable db client and execution result validator" +``` + +### Task 8: End-to-end verification and regression checks + +**Files:** +- Verify: `tests/skill_scripts/*.py` +- Verify: `skill_scripts/*.py` + +- [ ] **Step 1: Run full test suite** + +Run: `pytest tests/skill_scripts -v` +Expected: PASS + +- [ ] **Step 2: Compile check** + +Run: `python3 -m compileall skill_scripts` +Expected: no syntax errors + +- [ ] **Step 3: CLI rule mode sanity** + +Run: `python3 -m skill_scripts.cli_generate_select --mode rule --prompt "查詢2026年的工程預算明細"` +Expected: SELECT statement + deterministic behavior + +- [ ] **Step 4: CLI llm-first fallback sanity** + +Run: `python3 -m skill_scripts.cli_generate_select --mode shadow --prompt "查詢2026年的工程預算明細"` +Expected: SELECT statement + `ROUTE:shadow_rule` + `CANDIDATE_SQL:` + +- [ ] **Step 5: CLI llm-first fallback sanity** + +Run: `python3 -m skill_scripts.cli_generate_select --mode llm-first --llm-provider none --prompt "查詢2026年的工程預算明細"` +Expected: SELECT statement + route metadata (`fallback_rule`) when provider not configured + +- [ ] **Step 6: DB client config sanity** + +Run: + +```bash +DB_DRIVER=mssql DB_CONNECTION_STRING="Server=127.0.0.1;Database=wferp_test;" DB_ENV=test python3 - <<'PY' +from skill_scripts.database_client import DatabaseConfig +print(DatabaseConfig.from_env()) +PY +``` + +Expected: config printed with env=`test` + +- [ ] **Step 7: Commit** + +```bash +git add . +git commit -m "feat: implement dual-track llm sql generation with strict safety fallback" +``` + +--- + +## Self-Review (completed) + +1. **Spec coverage** + - context slicing: Task 1 + - LLM provider contract + envelope: Task 2 + - metadata validation: Task 3 + - dual-track routing/fallback with confidence + shadow mode: Task 4 + - CLI integration + observability: Task 5 + - prompt/sql consistency checks: Task 6 + - database connection abstraction + execution validation: Task 7 + - verification and regression: Task 8 + +2. **Placeholder scan** + - no placeholder markers left in tasks; all steps include concrete files, code, commands. + +3. **Type consistency** + - route contract is `tuple[str, JsonDict]` + - metadata validator contract is `tuple[bool, str]` + - consistency validator contract is `tuple[bool, str]` + - router uses existing `generate_select_sql(prompt, bundle)` signature consistently. diff --git a/docs/superpowers/plans/2026-04-21-workflow-erp-sql-generation-skill.md b/docs/superpowers/plans/2026-04-21-workflow-erp-sql-generation-skill.md new file mode 100644 index 0000000..d92e317 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-workflow-erp-sql-generation-skill.md @@ -0,0 +1,440 @@ +# Workflow ERP SQL Generation Skill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a skill that transforms natural language prompts into SQL Server 2000-compatible, SELECT-only SQL using Workflow ERP schema metadata. + +**Architecture:** Use a hybrid pipeline: deterministic metadata indexing + relationship inference + guarded SQL generation. The generator never emits non-SELECT statements and validates SQL Server 2000 compatibility before returning output. Artifacts are precomputed for fast runtime lookup. + +**Tech Stack:** Python 3, JSON metadata files in `_Source/`, pytest (new), markdown skill file. + +--- + +## File Structure (planned) + +- Create: `skill_scripts/schema_loader.py` +- Create: `skill_scripts/relationship_graph.py` +- Create: `skill_scripts/data_dictionary.py` +- Create: `skill_scripts/intent_parser.py` +- Create: `skill_scripts/sql2000_guard.py` +- Create: `skill_scripts/sql_generator.py` +- Create: `skill_scripts/cli_generate_select.py` +- Create: `skill_scripts/__init__.py` +- Create: `skill_scripts/artifacts/.gitkeep` +- Create: `skills/workflow-erp-sql-generator/SKILL.md` +- Create: `tests/skill_scripts/test_schema_loader.py` +- Create: `tests/skill_scripts/test_relationship_graph.py` +- Create: `tests/skill_scripts/test_data_dictionary.py` +- Create: `tests/skill_scripts/test_sql2000_guard.py` +- Create: `tests/skill_scripts/test_sql_generator.py` + +### Task 1: Scaffold package and schema loader + +**Files:** +- Create: `skill_scripts/__init__.py` +- Create: `skill_scripts/schema_loader.py` +- Test: `tests/skill_scripts/test_schema_loader.py` + +- [ ] **Step 1: Write the failing loader test** + +```python +from skill_scripts.schema_loader import load_schema_bundle + +def test_load_schema_bundle_has_required_keys(): + bundle = load_schema_bundle("_Source") + assert set(bundle.keys()) == { + "modules", "tables", "fields", "index_keys" + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/skill_scripts/test_schema_loader.py -v` +Expected: FAIL with import/module-not-found or function-not-found + +- [ ] **Step 3: Write minimal loader implementation** + +```python +# skill_scripts/schema_loader.py +import json +from pathlib import Path + +def _read_json(path: Path): + with path.open("r", encoding="utf-8") as f: + return json.load(f) + +def load_schema_bundle(source_dir: str): + base = Path(source_dir) + return { + "modules": _read_json(base / "MoudleName.json"), + "tables": _read_json(base / "TableName.json"), + "fields": _read_json(base / "TableStructure.json"), + "index_keys": _read_json(base / "TableIndexKey.json"), + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/skill_scripts/test_schema_loader.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/__init__.py skill_scripts/schema_loader.py tests/skill_scripts/test_schema_loader.py +git commit -m "feat: add schema bundle loader for ERP metadata" +``` + +### Task 2: Build relationship graph with confidence and cardinality + +**Files:** +- Create: `skill_scripts/relationship_graph.py` +- Test: `tests/skill_scripts/test_relationship_graph.py` + +- [ ] **Step 1: Write failing graph inference tests** + +```python +from skill_scripts.relationship_graph import infer_relationships + +def test_infer_relationships_returns_edges_with_confidence(): + edges = infer_relationships(fields=[], index_keys=[]) + assert isinstance(edges, list) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/skill_scripts/test_relationship_graph.py -v` +Expected: FAIL with missing implementation + +- [ ] **Step 3: Implement minimal inference engine** + +```python +def infer_relationships(fields, index_keys): + # v1 minimal shape; full rules added incrementally + return [] +``` + +- [ ] **Step 4: Add confidence/cardinality rule tests** + +```python +def test_edge_has_confidence_and_cardinality_keys(sample_edges): + edge = sample_edges[0] + assert edge["confidence"] in {"high", "medium", "low"} + assert edge["cardinality"] in {"1:1", "1:N", "N:N", "unknown"} +``` + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/relationship_graph.py tests/skill_scripts/test_relationship_graph.py +git commit -m "feat: add relationship inference graph with confidence metadata" +``` + +### Task 3: Build field-centric data dictionary + +**Files:** +- Create: `skill_scripts/data_dictionary.py` +- Test: `tests/skill_scripts/test_data_dictionary.py` + +- [ ] **Step 1: Write failing dictionary test** + +```python +from skill_scripts.data_dictionary import build_field_index + +def test_build_field_index_maps_field_to_table_fields(sample_fields): + index = build_field_index(sample_fields) + assert "TA001" in index +``` +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_data_dictionary.py -v` +Expected: FAIL with function missing + +- [ ] **Step 3: Implement field reverse-index** + +```python +from collections import defaultdict + +def build_field_index(fields): + out = defaultdict(list) + for row in fields: + out[row["FieldNo"]].append(f"{row['TableNo']}.{row['FieldNo']}") + return dict(out) +``` + +- [ ] **Step 4: Add alias-normalization test** + +```python +def test_build_alias_index_normalizes_case(sample_fields): + from skill_scripts.data_dictionary import build_alias_index + + alias_index = build_alias_index(sample_fields) + assert "ta001" in alias_index + assert any(v.endswith(".TA001") for v in alias_index["ta001"]) +``` + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/data_dictionary.py tests/skill_scripts/test_data_dictionary.py +git commit -m "feat: add field-centric data dictionary indexes" +``` + +### Task 4: Implement SQL2000 + SELECT-only guard + +**Files:** +- Create: `skill_scripts/sql2000_guard.py` +- Test: `tests/skill_scripts/test_sql2000_guard.py` + +- [ ] **Step 1: Write failing policy tests** + +```python +from skill_scripts.sql2000_guard import validate_sql + +def test_rejects_non_select_sql(): + ok, code = validate_sql("DELETE FROM ACPTA") + assert ok is False and code == "NON_SELECT_INTENT" + +def test_rejects_cte_for_sql2000(): + ok, code = validate_sql("WITH x AS (SELECT 1 a) SELECT * FROM x") + assert ok is False and code == "UNSUPPORTED_SQL2000_FEATURE" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/skill_scripts/test_sql2000_guard.py -v` +Expected: FAIL with missing validator + +- [ ] **Step 3: Implement validator** + +```python +FORBIDDEN_NON_SELECT = {"insert", "update", "delete", "create", "alter", "drop", "merge", "truncate"} +FORBIDDEN_SQL2000 = {" with ", " over ", " offset ", " fetch ", " intersect ", " except "} + +def validate_sql(sql: str): + s = f" {sql.lower()} " + if any(tok in s for tok in FORBIDDEN_NON_SELECT): + return False, "NON_SELECT_INTENT" + if not s.strip().startswith("select"): + return False, "NON_SELECT_INTENT" + if any(tok in s for tok in FORBIDDEN_SQL2000): + return False, "UNSUPPORTED_SQL2000_FEATURE" + return True, "OK" +``` + +- [ ] **Step 4: Re-run tests** + +Run: `pytest tests/skill_scripts/test_sql2000_guard.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/sql2000_guard.py tests/skill_scripts/test_sql2000_guard.py +git commit -m "feat: enforce sql2000 and select-only safety policies" +``` + +### Task 5: Implement intent parser and SQL assembler + +**Files:** +- Create: `skill_scripts/intent_parser.py` +- Create: `skill_scripts/sql_generator.py` +- Test: `tests/skill_scripts/test_sql_generator.py` + +- [ ] **Step 1: Write failing generation tests** + +```python +from skill_scripts.sql_generator import generate_select_sql + +def test_generate_select_sql_returns_select_statement(sample_bundle): + sql = generate_select_sql("查詢採購單號與日期", sample_bundle) + assert sql.lower().startswith("select") +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `pytest tests/skill_scripts/test_sql_generator.py -v` +Expected: FAIL with missing parser/generator + +- [ ] **Step 3: Implement minimal parser + assembler** + +```python +def parse_intent(prompt: str): + return {"raw": prompt, "top": None, "filters": []} + +def generate_select_sql(prompt: str, bundle: dict): + # minimal v1 deterministic stub, expanded by tests + return "SELECT TOP 50 * FROM [DSCSYS].[dbo].[ADMMC]" +``` + +- [ ] **Step 4: Validate generated SQL through guard** + +Run: `pytest tests/skill_scripts/test_sql_generator.py -v` +Expected: PASS including guard integration assertions + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/intent_parser.py skill_scripts/sql_generator.py tests/skill_scripts/test_sql_generator.py +git commit -m "feat: add intent parsing and deterministic select sql assembly" +``` + +### Task 6: Add CLI entrypoint and artifact builders + +**Files:** +- Create: `skill_scripts/cli_generate_select.py` +- Create: `skill_scripts/artifacts/.gitkeep` +- Modify: `skill_scripts/schema_loader.py` + +- [ ] **Step 1: Write failing CLI smoke test** + +```python +def test_cli_outputs_sql_string(capsys): + from skill_scripts.cli_generate_select import main + import sys + + old_argv = sys.argv + sys.argv = ["cli_generate_select", "--prompt", "查詢客戶主檔"] + try: + main() + finally: + sys.argv = old_argv + + captured = capsys.readouterr() + assert captured.out.strip().lower().startswith("select") +``` + +- [ ] **Step 2: Implement CLI main()** + +```python +import argparse + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--prompt", default="") + parser.add_argument("--source", default="_Source") + parser.add_argument("--build-artifacts", action="store_true") + args = parser.parse_args() + + if args.build_artifacts: + # write precomputed artifacts to skill_scripts/artifacts/ + return + + from skill_scripts.schema_loader import load_schema_bundle + from skill_scripts.sql_generator import generate_select_sql + + bundle = load_schema_bundle(args.source) + sql = generate_select_sql(args.prompt, bundle) + print(sql) +``` + +- [ ] **Step 3: Add build command for artifacts** + +Run: `python -m skill_scripts.cli_generate_select --build-artifacts` +Expected: files created under `skill_scripts/artifacts/` + +- [ ] **Step 4: Verify CLI generation path** + +Run: `python -m skill_scripts.cli_generate_select --prompt "查詢客戶主檔"` +Expected: a single SQL `SELECT` statement output + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/cli_generate_select.py skill_scripts/artifacts/.gitkeep skill_scripts/schema_loader.py +git commit -m "feat: add cli for artifact build and select sql generation" +``` + +### Task 7: Create skill contract file and usage guardrails + +**Files:** +- Create: `skills/workflow-erp-sql-generator/SKILL.md` + +- [ ] **Step 1: Write failing contract checks (manual checklist)** + +Run checklist: +- Skill states SELECT-only policy +- Skill states SQL Server 2000 compatibility rules +- Skill explains required inputs and error codes + +Expected: checklist initially fails (file missing) + +- [ ] **Step 2: Implement skill markdown contract** + +```markdown +# workflow-erp-sql-generator +- Input: natural language query +- Output: one SQL Server 2000-compatible SELECT statement +- Must refuse: INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/EXEC +- Must validate SQL through sql2000_guard.validate_sql +``` + +- [ ] **Step 3: Verify skill contract manually** + +Run: `python -m skill_scripts.cli_generate_select --prompt "刪除資料"` +Expected: structured refusal with `NON_SELECT_INTENT` + +- [ ] **Step 4: Commit** + +```bash +git add skills/workflow-erp-sql-generator/SKILL.md +git commit -m "docs: add workflow erp sql generator skill contract" +``` + +### Task 8: Run full verification + +**Files:** +- Test: `tests/skill_scripts/*.py` +- Verify: generated SQL outputs from CLI examples + +- [ ] **Step 1: Run all tests** + +Run: `pytest tests/skill_scripts -v` +Expected: PASS + +- [ ] **Step 2: Run lint-equivalent sanity checks** + +Run: `python -m compileall skill_scripts` +Expected: no syntax errors + +- [ ] **Step 3: Run end-to-end examples** + +Run: + +```bash +python -m skill_scripts.cli_generate_select --prompt "查詢採購單前 20 筆" +python -m skill_scripts.cli_generate_select --prompt "更新採購單狀態" +``` + +Expected: +- first command outputs valid SQL2000 `SELECT` +- second command returns `NON_SELECT_INTENT` + +- [ ] **Step 4: Final commit** + +```bash +git add . +git commit -m "feat: implement workflow erp select-only sql2000 generation skill" +``` + +--- + +## Self-Review (completed) + +1. **Spec coverage:** + - schema indexing: Task 1 + - relationship/cardinality inference: Task 2 + - data dictionary: Task 3 + - SQL2000 + select-only safety: Task 4 + - NL-to-SQL generation: Task 5 + - acceleration scripts/artifacts: Task 6 + - skill behavior contract: Task 7 + - verification: Task 8 + +2. **Placeholder scan:** + - Checked for unfinished markers and removed all of them from this plan. + +3. **Type consistency:** + - `generate_select_sql(prompt: str, bundle: dict)` and `validate_sql(sql: str)` are consistent across tasks. diff --git a/docs/superpowers/plans/2026-06-18-wferp-report-harness-parity.md b/docs/superpowers/plans/2026-06-18-wferp-report-harness-parity.md new file mode 100644 index 0000000..8d321b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-wferp-report-harness-parity.md @@ -0,0 +1,1413 @@ +# WFERP Report Harness Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task begins with failing tests, implements the smallest production-quality vertical slice, then commits only the files in that task. + +**Goal:** Build `wferp-report` into a product-grade local Codex skill that executes a beautiful-article-style harness: prompt/Excel intake, WFERP schema-aware SELECT SQL generation, user checkpoints in a local React companion, safe DB execution, data preview, report format selection, scaffolded React report generation, validator evidence, and minimal-slice repair. + +**Architecture:** Keep the local skill outside the repository at `/home/timmypai/.codex/skills/wferp-report/`; keep executable runtime inside this repository under `skill_scripts/`, `report_renderer/`, `scripts/`, `report_designs/`, and tests. Use fixed React checkpoint components driven by JSON payloads, and generate one per-run final report workspace from a strict scaffold template. + +**Tech Stack:** Python 3, pytest, local file-backed harness state, `http.server`-based checkpoint companion, React 19, Vite, Vitest, Testing Library, SQLite for first local E2E, Docker PostgreSQL as formal MSSQL substitute E2E, WFERP `_Source` schema metadata. + +--- + +## Approved Spec + +Implement against: + +- `docs/superpowers/specs/2026-06-18-wferp-report-harness-parity-design.md` + +The design has already been reviewed by the user. Do not reduce scope. Phases may be delivered sequentially, but each task must leave a production-grade vertical slice with tests and measurable acceptance criteria. + +## Goal Prompt For Execution + +Use this prompt to execute the plan in a fresh Codex session: + +```text +使用 superpowers:subagent-driven-development 與 TDD 執行 /home/timmypai/.codex/worktrees/5f5b/wferp/docs/superpowers/plans/2026-06-18-wferp-report-harness-parity.md。 + +要求: +1. 每個 Task 派一個 fresh subagent 實作,主 agent 只做 task packet、review、整合與下一步決策。 +2. 每個 Task 必須先寫 failing tests,確認失敗訊息符合 plan,再做最小產品級實作。 +3. 每個 Task 完成後需提供 evidence packet:修改檔案、測試命令、失敗前證據、通過後證據、量化驗收、剩餘風險。 +4. 不得使用 fake、mock、smoke 來替代 E2E。費用分析 E2E 必須先跑 SQLite-first,再跑 Docker PostgreSQL formal MSSQL substitute,建立真實 schema、seed 真實測試資料、執行真實 SQL、驗證 rows/columns/aggregates/percentages/exclusions。 +5. 使用者可見文件與 skill 文案使用繁體中文。程式命名與 commit message 可使用英文。 +6. 嚴格保護安全邊界:只允許唯讀 SELECT;未經 checkpoint confirmation 不得連 DB;瀏覽器端不得連 DB、不得執行 SQL、不得存 credentials。 +7. 每個 Task 通過後做小 commit。不要 stage unrelated worktree changes。 +8. 若 validator 或 E2E 失敗,依 repair policy 做最小垂直切片修復,不得重寫整個流程掩蓋單點失敗。 +``` + +## File Structure + +### Repository Runtime + +- Create: `skill_scripts/checkpoint_companion.py` + Local HTTP companion server, checkpoint JSON loading, confirmation POST handling, audit append, stale-check protection. +- Modify: `skill_scripts/report_harness_state.py` + Add audit directory, confirmation files, checkpoint version/hash, phase directories matching spec. +- Modify: `skill_scripts/report_harness.py` + Use confirmation files, enforce hard gates, append repair logs, expose run-root defaults. +- Modify: `skill_scripts/cli_report_harness.py` + Add commands for starting companion, emitting current checkpoint, scaffolding report, validating final delivery. +- Create: `skill_scripts/report_scaffold.py` + Copy scaffold template into per-run workspace, write payload, validate one-section-one-file protocol. +- Modify: `skill_scripts/excel_intake.py` + Parse uploaded Excel requirements, distinguish DB fields from user formula fields, capture formula lineage. +- Modify: `skill_scripts/validator_contracts.py` + Add validator roles from spec, JSON evidence contract, pass/fail aggregation, residual-risk handling. +- Modify: `skill_scripts/report_catalog.py` + Read `report_designs/index.json`, load selected profile, validate required metadata. +- Modify: `scripts/validate_local_wferp_report_skill.py` + Validate full local skill parity structure, required references, scripts, catalog, scaffold template. + +### React Checkpoint App + +- Modify: `report_renderer/src/App.tsx` +- Modify: `report_renderer/src/components/CheckpointPage.tsx` +- Modify: `report_renderer/src/components/DataPreviewTable.tsx` +- Modify: `report_renderer/src/components/ReportOptionPanel.tsx` +- Create: `report_renderer/src/components/CheckpointShell.tsx` +- Create: `report_renderer/src/components/ManagementView.tsx` +- Create: `report_renderer/src/components/TechnicalView.tsx` +- Create: `report_renderer/src/components/FieldFormulaReview.tsx` +- Create: `report_renderer/src/components/SqlReviewPanel.tsx` +- Create: `report_renderer/src/components/AggregateCheckPanel.tsx` +- Create: `report_renderer/src/components/ValidatorEvidencePanel.tsx` +- Create: `report_renderer/src/components/ActionBar.tsx` +- Create: `report_renderer/src/components/ChartBlock.tsx` +- Create: `report_renderer/src/components/InsightBlock.tsx` +- Create: `report_renderer/src/components/RecommendationList.tsx` +- Create: `report_renderer/src/components/RawBlockNotice.tsx` +- Modify: `report_renderer/src/styles.css` +- Modify: `report_renderer/tests/renderer.spec.ts` + +### Local Skill Outside Repo + +- Modify: `/home/timmypai/.codex/skills/wferp-report/SKILL.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/manifest.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/README.md` +- Create missing references under `/home/timmypai/.codex/skills/wferp-report/references/` +- Create: `/home/timmypai/.codex/skills/wferp-report/report_designs/index.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/` +- Create: `/home/timmypai/.codex/skills/wferp-report/examples/` +- Modify local skill scripts under `/home/timmypai/.codex/skills/wferp-report/scripts/` + +### Tests + +- Create: `tests/skill_scripts/test_checkpoint_companion.py` +- Modify: `tests/skill_scripts/test_report_harness_state.py` +- Modify: `tests/skill_scripts/test_report_harness.py` +- Create: `tests/skill_scripts/test_report_scaffold.py` +- Modify: `tests/skill_scripts/test_excel_intake.py` +- Modify: `tests/skill_scripts/test_validator_contracts.py` +- Modify: `tests/skill_scripts/test_report_catalog.py` +- Modify: `tests/scripts/test_validate_local_wferp_report_skill.py` +- Modify: `tests/skill_scripts/test_expense_analysis_sqlite_e2e.py` +- Modify: `tests/skill_scripts/test_expense_analysis_postgres_e2e.py` +- Modify: `report_renderer/tests/renderer.spec.ts` + +--- + +## Task 1: Local Skill Parity Structure + +**Files:** +- Modify: `/home/timmypai/.codex/skills/wferp-report/SKILL.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/manifest.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/README.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/checkpoint-payload-schema.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/report-payload-schema.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/component-policy.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/rawblock-policy.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/scaffold.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/section-build.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/report-plan-template.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/review-checklist.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/repair-policy.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/references/html-output.md` +- Create: `/home/timmypai/.codex/skills/wferp-report/report_designs/index.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/examples/checkpoint-sql-review.json` +- Modify: `scripts/validate_local_wferp_report_skill.py` +- Modify: `tests/scripts/test_validate_local_wferp_report_skill.py` + +- [ ] **Step 1: Write failing validator tests** + +Add test cases requiring the beautiful-article-style structure: + +```python +def test_validator_requires_harness_parity_files(tmp_path): + skill_dir = tmp_path / "wferp-report" + (skill_dir / "references").mkdir(parents=True) + (skill_dir / "scripts").mkdir() + (skill_dir / "report_designs").mkdir() + (skill_dir / "SKILL.md").write_text("## 背景原則\n", encoding="utf-8") + + result = validate_skill_directory(skill_dir) + + missing = {item["path"] for item in result["missing"]} + assert "manifest.json" in missing + assert "references/checkpoint-payload-schema.md" in missing + assert "references/rawblock-policy.md" in missing + assert "references/scaffold.md" in missing + assert "report_designs/index.json" in missing + assert "assets/scaffold-template/package.json" in missing +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +``` + +Expected: FAIL because the validator does not require every parity file. + +- [ ] **Step 3: Implement validator requirements** + +Update `scripts/validate_local_wferp_report_skill.py` with this required file set: + +```python +REQUIRED_FILES = [ + "SKILL.md", + "manifest.json", + "README.md", + "references/harness.md", + "references/db-config.md", + "references/excel-intake.md", + "references/schema-context.md", + "references/sql-safety.md", + "references/checkpoint-payload-schema.md", + "references/report-payload-schema.md", + "references/component-policy.md", + "references/rawblock-policy.md", + "references/scaffold.md", + "references/section-build.md", + "references/report-plan-template.md", + "references/review-checklist.md", + "references/repair-policy.md", + "references/html-output.md", + "references/validators.md", + "references/e2e-expense-analysis.md", + "scripts/scaffold-report.sh", + "scripts/validate-skill.sh", + "scripts/print-expense-fixture-sql.sh", + "scripts/run-expense-sqlite-e2e.sh", + "scripts/run-expense-postgres-e2e.sh", + "report_designs/index.json", + "report_designs/design.md", + "report_designs/financial-control.md", + "report_designs/executive-summary.md", + "report_designs/detail-ledger.md", + "report_designs/exception-audit.md", + "report_designs/operations-review.md", + "report_designs/trend-briefing.md", + "assets/scaffold-template/package.json", + "assets/scaffold-template/index.html", + "assets/scaffold-template/report/Report.tsx", +] +``` + +- [ ] **Step 4: Update local skill documents** + +Rewrite `/home/timmypai/.codex/skills/wferp-report/SKILL.md` in Chinese with the exact chapter mapping from the spec: + +```markdown +## 背景原則 +## 邊界 +## 工作流總覽 +## 硬性質檢協議 +## 各階段文件讀取指南 +## Phase 0 —— Intake +## Phase 1 —— Source / Excel Requirement +## Phase 2 —— Report Planning +## Phase 3 —— Field & Formula Checkpoint +## Phase 4 —— SQL Review Checkpoint +## Phase 5 —— Confirmed DB Execution +## Phase 6 —— Data Preview Checkpoint +## Phase 7 —— Report Selection Checkpoint +## Phase 8 —— Final Report Scaffold +## Phase 9 —— Section Build +## Phase 10 —— Final Review +## Phase 11 —— Repair +## Phase 12 —— Delivery +## 預設策略 +## 成功標準 +## 相關資源 +``` + +Every phase must include: + +```text +目標: +輸入: +必讀 references: +執行步驟: +產物: +停止條件: +使用者 checkpoint: +validator: +失敗時 repair slice: +``` + +- [ ] **Step 5: Run validator** + +Run: + +```bash +bash /home/timmypai/.codex/skills/wferp-report/scripts/validate-skill.sh +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +``` + +Expected: + +```text +wferp-report skill validation passed +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/validate_local_wferp_report_skill.py tests/scripts/test_validate_local_wferp_report_skill.py +git commit -m "docs: complete wferp report skill parity structure" +``` + +Local skill files live outside the repo; include their paths in the evidence packet instead of staging them. + +--- + +## Task 2: Checkpoint Companion Server And Audit State + +**Files:** +- Create: `skill_scripts/checkpoint_companion.py` +- Modify: `skill_scripts/report_harness_state.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `skill_scripts/cli_report_harness.py` +- Create: `tests/skill_scripts/test_checkpoint_companion.py` +- Modify: `tests/skill_scripts/test_report_harness_state.py` +- Modify: `tests/skill_scripts/test_report_harness.py` + +- [ ] **Step 1: Write failing companion tests** + +Create `tests/skill_scripts/test_checkpoint_companion.py`: + +```python +from __future__ import annotations + +import json +from pathlib import Path +from urllib.request import Request, urlopen + +from skill_scripts.checkpoint_companion import CheckpointCompanionServer +from skill_scripts.report_harness import ReportHarness + + +def post_json(url: str, payload: dict) -> dict: + request = Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +def test_confirmation_post_writes_confirmation_and_audit(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_sql_review("SELECT department, amount FROM expenses", {"status": "pass"}) + + with CheckpointCompanionServer.serve(tmp_path / "run-001") as server: + result = post_json( + f"{server.base_url}/api/runs/run-001/checkpoints/sql_review/confirm", + { + "action": "同意查詢", + "checkpointId": "sql_review", + "comment": "條件正確,可以查詢", + "selectedOptions": {"view": "management"}, + }, + ) + + assert result["status"] == "confirmed" + confirmation = json.loads( + (tmp_path / "run-001" / "checkpoints" / "02_sql_review.confirmation.json").read_text(encoding="utf-8") + ) + assert confirmation["action"] == "同意查詢" + assert confirmation["comment"] == "條件正確,可以查詢" + audit_lines = (tmp_path / "run-001" / "audit" / "events.jsonl").read_text(encoding="utf-8").splitlines() + assert len(audit_lines) == 1 + assert json.loads(audit_lines[0])["event"] == "checkpoint_confirmed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pytest tests/skill_scripts/test_checkpoint_companion.py -v +``` + +Expected: FAIL because `skill_scripts.checkpoint_companion` does not exist. + +- [ ] **Step 3: Implement companion server** + +Create `skill_scripts/checkpoint_companion.py` with: + +```python +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +from threading import Thread +from typing import Iterator +from urllib.parse import urlparse + +from skill_scripts.report_harness import ReportHarness +from skill_scripts.report_harness_state import append_audit_event, write_confirmation + + +@dataclass +class RunningCheckpointServer: + httpd: ThreadingHTTPServer + thread: Thread + base_url: str + + +class CheckpointCompanionServer: + @staticmethod + @contextmanager + def serve(run_dir: str | Path, host: str = "127.0.0.1", port: int = 0) -> Iterator[RunningCheckpointServer]: + run_path = Path(run_dir) + + class Handler(BaseHTTPRequestHandler): + def _json(self, status: int, payload: dict) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + parts = [part for part in parsed.path.split("/") if part] + if len(parts) != 6 or parts[:2] != ["api", "runs"] or parts[3] != "checkpoints" or parts[5] != "confirm": + self._json(404, {"status": "not_found"}) + return + run_id = parts[2] + checkpoint = parts[4] + if run_path.name != run_id: + self._json(409, {"status": "wrong_run"}) + return + content_length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(content_length).decode("utf-8")) + harness = ReportHarness(run_path) + harness.confirm(checkpoint, payload["action"]) + confirmation = write_confirmation(run_path, checkpoint, payload) + append_audit_event(run_path, "checkpoint_confirmed", {"checkpoint": checkpoint, "action": payload["action"]}) + self._json(200, {"status": "confirmed", "confirmation": confirmation}) + + def log_message(self, format: str, *args: object) -> None: + return + + httpd = ThreadingHTTPServer((host, port), Handler) + thread = Thread(target=httpd.serve_forever, daemon=True) + thread.start() + running = RunningCheckpointServer( + httpd=httpd, + thread=thread, + base_url=f"http://{host}:{httpd.server_address[1]}", + ) + try: + yield running + finally: + httpd.shutdown() + thread.join(timeout=5) +``` + +- [ ] **Step 4: Add state helpers** + +Add helpers to `skill_scripts/report_harness_state.py`: + +```python +def append_audit_event(run_dir: str | Path, event: str, payload: dict[str, Any]) -> dict[str, Any]: + entry = {"event": event, "payload": payload, "created_at": _now()} + path = Path(run_dir) / "audit" / "events.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry + + +def write_confirmation(run_dir: str | Path, checkpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + if checkpoint not in CHECKPOINT_DEFINITIONS: + raise ValueError(f"Unknown checkpoint: {checkpoint}") + definition = CHECKPOINT_DEFINITIONS[checkpoint] + confirmation = { + "checkpoint": checkpoint, + "action": payload["action"], + "comment": payload.get("comment", ""), + "selectedOptions": payload.get("selectedOptions", {}), + "created_at": _now(), + } + filename = definition["file"].replace(".json", ".confirmation.json") + _write_json(Path(run_dir) / "checkpoints" / filename, confirmation) + return confirmation +``` + +Also update `create_report_run()` to create `source`, `plan`, `audit`, `review`, and `report/payload`. + +- [ ] **Step 5: Expose CLI companion command** + +Add command: + +```bash +python3 -m skill_scripts.cli_report_harness serve-checkpoint --run-dir +``` + +Expected stdout: + +```text +checkpoint_companion_url=http://127.0.0.1:/runs//checkpoints/current +``` + +- [ ] **Step 6: Run tests** + +```bash +pytest tests/skill_scripts/test_checkpoint_companion.py tests/skill_scripts/test_report_harness_state.py tests/skill_scripts/test_report_harness.py -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add skill_scripts/checkpoint_companion.py skill_scripts/report_harness_state.py skill_scripts/report_harness.py skill_scripts/cli_report_harness.py tests/skill_scripts/test_checkpoint_companion.py tests/skill_scripts/test_report_harness_state.py tests/skill_scripts/test_report_harness.py +git commit -m "feat: add checkpoint companion confirmation server" +``` + +--- + +## Task 3: Product Checkpoint React App + +**Files:** +- Modify: `report_renderer/src/App.tsx` +- Modify: `report_renderer/src/components/CheckpointPage.tsx` +- Modify: `report_renderer/src/components/DataPreviewTable.tsx` +- Modify: `report_renderer/src/components/ReportOptionPanel.tsx` +- Create: `report_renderer/src/components/CheckpointShell.tsx` +- Create: `report_renderer/src/components/ManagementView.tsx` +- Create: `report_renderer/src/components/TechnicalView.tsx` +- Create: `report_renderer/src/components/FieldFormulaReview.tsx` +- Create: `report_renderer/src/components/SqlReviewPanel.tsx` +- Create: `report_renderer/src/components/AggregateCheckPanel.tsx` +- Create: `report_renderer/src/components/ValidatorEvidencePanel.tsx` +- Create: `report_renderer/src/components/ActionBar.tsx` +- Modify: `report_renderer/src/styles.css` +- Modify: `report_renderer/tests/renderer.spec.ts` + +- [ ] **Step 1: Write failing renderer tests** + +Add tests: + +```ts +it("renders management view by default and hides SQL until technical tab is selected", () => { + render(); + expect(screen.getByRole("heading", { name: "SQL 查詢確認" })).toBeInTheDocument(); + expect(screen.getByText("主管檢視")).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByText("SELECT department, amount FROM expenses")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "技術明細" })); + expect(screen.getByText("SELECT department, amount FROM expenses")).toBeInTheDocument(); +}); + +it("posts checkpoint confirmation to the harness endpoint", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "confirmed" }), { status: 200 }) + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "同意查詢" })); + await screen.findByText("已送出確認"); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/runs/run-001/checkpoints/sql_review/confirm", + expect.objectContaining({ method: "POST" }) + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd report_renderer +npm test +``` + +Expected: FAIL because fixed checkpoint app lacks tabs and POST confirmation. + +- [ ] **Step 3: Implement payload types** + +Extend `CheckpointPayload` in `report_renderer/src/App.tsx`: + +```ts +export interface CheckpointPayload { + kind: "checkpoint"; + checkpointId: string; + title: string; + step?: string; + confirmUrl?: string; + fieldFormulaReview?: FieldFormulaReviewPayload; + sqlReview?: SqlReview; + dataPreview?: DataPreview; + aggregateChecks?: AggregateCheck[]; + reportTypes?: ReportTypeChoice[]; + validatorEvidence?: ValidatorEvidence[]; + actions?: string[]; +} +``` + +- [ ] **Step 4: Implement CheckpointShell and ActionBar** + +`ActionBar` must POST: + +```ts +await fetch(confirmUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + checkpointId, + comment, + selectedOptions, + }), +}); +``` + +Show states: `idle`, `submitting`, `confirmed`, `failed`. + +- [ ] **Step 5: Implement management and technical views** + +Management view includes: + +- requirement summary +- field/formula confirmation +- data preview +- aggregate checks +- exceptions/risks +- next-step actions + +Technical view includes: + +- generated SQL +- schema/table/field mapping +- relationship path +- SQL safety checks +- execution environment +- validator evidence + +- [ ] **Step 6: Run renderer tests and build** + +```bash +cd report_renderer +npm test +npm run build +``` + +Expected: PASS and Vite build completes with no TypeScript errors. + +- [ ] **Step 7: Commit** + +```bash +git add report_renderer/src report_renderer/tests/renderer.spec.ts +git commit -m "feat: add product checkpoint companion UI" +``` + +--- + +## Task 4: Excel Requirement And Formula Lineage + +**Files:** +- Modify: `skill_scripts/excel_intake.py` +- Modify: `tests/skill_scripts/test_excel_intake.py` +- Create: `/home/timmypai/.codex/skills/wferp-report/examples/checkpoint-excel-confirmation.json` + +- [ ] **Step 1: Write failing Excel intake tests** + +Add tests that create a real `.xlsx` workbook with `openpyxl` or the bundled spreadsheet runtime. The workbook must contain: + +- sheet `需求欄位` +- DB fields: `部門代號`, `部門名稱`, `費用科目`, `金額` +- user formula fields: `費用占比 = 金額 / 總金額`, `異常旗標 = IF(金額>預算,"超支","正常")` +- output report sheet with linked formulas + +Test assertion: + +```python +assert requirement["db_fields"] == ["部門代號", "部門名稱", "費用科目", "金額"] +assert requirement["formula_fields"][0]["name"] == "費用占比" +assert requirement["formula_fields"][0]["formula"] == "=金額/總金額" +assert requirement["formula_fields"][0]["dependencies"] == ["金額", "總金額"] +assert requirement["report_outputs"][0]["sheet"] == "管理報表" +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/skill_scripts/test_excel_intake.py -v +``` + +Expected: FAIL because formula lineage and output report links are incomplete. + +- [ ] **Step 3: Implement parser contract** + +Return this shape: + +```python +{ + "source_type": "excel", + "db_fields": [ + {"label": "部門代號", "required": True, "matched_schema_field": None}, + {"label": "金額", "required": True, "matched_schema_field": None}, + ], + "formula_fields": [ + { + "name": "費用占比", + "formula": "=金額/總金額", + "dependencies": ["金額", "總金額"], + "requires_user_confirmation": True, + } + ], + "report_outputs": [ + {"sheet": "管理報表", "cells": [{"address": "B6", "formula": "=SUM(明細!D:D)"}]} + ], + "warnings": [], +} +``` + +- [ ] **Step 4: Add checkpoint example** + +Write `/home/timmypai/.codex/skills/wferp-report/examples/checkpoint-excel-confirmation.json` using the same shape and actions: + +```json +{ + "checkpointId": "excel_confirmation", + "title": "確認欄位與公式", + "actions": ["確認欄位與公式", "要求修正"] +} +``` + +- [ ] **Step 5: Run tests** + +```bash +pytest tests/skill_scripts/test_excel_intake.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skill_scripts/excel_intake.py tests/skill_scripts/test_excel_intake.py +git commit -m "feat: parse excel report requirements and formulas" +``` + +Local example file is outside repo; include it in evidence packet. + +--- + +## Task 5: Final Report Scaffold Workspace + +**Files:** +- Create: `skill_scripts/report_scaffold.py` +- Modify: `skill_scripts/cli_report_harness.py` +- Create: `tests/skill_scripts/test_report_scaffold.py` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/package.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/vite.config.ts` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/tsconfig.json` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/index.html` +- Create: `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/report/Report.tsx` +- Create scaffold component and directory files under `/home/timmypai/.codex/skills/wferp-report/assets/scaffold-template/report/` + +- [ ] **Step 1: Write failing scaffold tests** + +```python +def test_scaffold_creates_one_section_per_file_workspace(tmp_path): + run_dir = tmp_path / "run-001" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + + result = scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary", "kpi-overview", "data-table"], + payload={"approved_query_result": {"rows": []}}, + ) + + assert (run_dir / "report" / "Report.tsx").exists() + assert (run_dir / "report" / "sections" / "01-executive-summary.tsx").exists() + assert (run_dir / "report" / "sections" / "02-kpi-overview.tsx").exists() + assert (run_dir / "report" / "sections" / "03-data-table.tsx").exists() + assert (run_dir / "report" / "payload" / "approved-query-result.json").exists() + assert result["section_count"] == 3 +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/skill_scripts/test_report_scaffold.py -v +``` + +Expected: FAIL because `report_scaffold.py` does not exist. + +- [ ] **Step 3: Implement scaffold runtime** + +Create: + +```python +def scaffold_report_workspace(run_dir: Path, template_dir: Path, sections: list[str], payload: dict) -> dict: + copy_template(template_dir, run_dir) + write_payload(run_dir, payload) + create_section_files(run_dir, sections) + validate_report_protocol(run_dir) + return {"section_count": len(sections), "run_dir": str(run_dir)} +``` + +Protocol validation: + +- `Report.tsx` imports generated section files. +- Every section file exports one React component. +- `raw-blocks/` exists but is empty unless selected design requires it. +- `payload/approved-query-result.json` exists. + +- [ ] **Step 4: Add CLI command** + +```bash +python3 -m skill_scripts.cli_report_harness scaffold-report --run-dir --design financial-control +``` + +Expected output: + +```json +{"status":"scaffolded","section_count":5} +``` + +- [ ] **Step 5: Run tests** + +```bash +pytest tests/skill_scripts/test_report_scaffold.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skill_scripts/report_scaffold.py skill_scripts/cli_report_harness.py tests/skill_scripts/test_report_scaffold.py +git commit -m "feat: scaffold per-run react report workspaces" +``` + +Local scaffold template is outside repo; include it in evidence packet. + +--- + +## Task 6: ChartBlock, DataTable, RawBlock Policy + +**Files:** +- Create: `report_renderer/src/components/ChartBlock.tsx` +- Modify: `report_renderer/src/components/DataPreviewTable.tsx` +- Create: `report_renderer/src/components/InsightBlock.tsx` +- Create: `report_renderer/src/components/RecommendationList.tsx` +- Create: `report_renderer/src/components/RawBlockNotice.tsx` +- Modify: `report_renderer/src/components/ReportPage.tsx` +- Modify: `report_renderer/src/styles.css` +- Modify: `report_renderer/tests/renderer.spec.ts` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/component-policy.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/rawblock-policy.md` + +- [ ] **Step 1: Write failing component tests** + +Add renderer tests for: + +- bar, stacked bar, line, area, pie/donut, combo chart labels +- chart suitability warning when category count exceeds configured limit +- table sorting +- table text filter +- table number range filter +- table show/hide column +- summary row +- CSV export button creates a downloadable blob +- RawBlock policy notice renders metadata and risk level + +Example: + +```ts +it("sorts and filters management report rows", async () => { + render(); + fireEvent.change(screen.getByLabelText("搜尋全部欄位"), { target: { value: "行政部" } }); + expect(screen.getByText("行政部")).toBeInTheDocument(); + expect(screen.queryByText("研發部")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "金額排序" })); + expect(screen.getAllByTestId("data-row")[0]).toHaveTextContent("12,000"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd report_renderer +npm test +``` + +Expected: FAIL because the components lack product table/chart controls. + +- [ ] **Step 3: Implement ChartBlock without new dependencies** + +Use accessible SVG for Phase 1 chart types to avoid adding a chart dependency before design review: + +```ts +export type ChartType = "bar" | "stacked-bar" | "line" | "area" | "pie" | "donut" | "combo"; +``` + +`ChartBlock` must render title, subtitle, legend, tooltip labels through `aria-label`, empty/error states, and a suitability warning when the data shape is wrong. + +- [ ] **Step 4: Implement DataTable controls** + +Product Phase 1 table controls: + +- sorting +- text contains filtering +- number/date range filtering +- category multi-select +- show/hide columns +- frozen key column styling +- summary row +- group subtotal row +- conditional formatting +- number/date/percent/currency formatting +- full-table search +- CSV export +- pagination for large row sets + +- [ ] **Step 5: Implement RawBlockNotice** + +`RawBlockNotice` displays: + +```ts +{ + id: string; + title: string; + purpose: string; + dataDependencies: string[]; + riskLevel: "low" | "medium" | "high"; +} +``` + +It must not execute arbitrary code. It only renders metadata and policy warnings. + +- [ ] **Step 6: Run tests and build** + +```bash +cd report_renderer +npm test +npm run build +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add report_renderer/src report_renderer/tests/renderer.spec.ts +git commit -m "feat: add product chart and table report components" +``` + +Local policy docs are outside repo; include them in evidence packet. + +--- + +## Task 7: Report Design Catalog Enforcement + +**Files:** +- Modify: `report_designs/README.md` +- Modify: `report_designs/design.md` +- Create: `report_designs/index.json` +- Modify: `report_designs/financial-control.md` +- Modify: `report_designs/executive-summary.md` +- Modify: `report_designs/detail-ledger.md` +- Modify: `report_designs/exception-audit.md` +- Modify: `report_designs/operations-review.md` +- Modify: `report_designs/trend-briefing.md` +- Modify: `skill_scripts/report_catalog.py` +- Modify: `tests/skill_scripts/test_report_catalog.py` +- Mirror the same catalog under `/home/timmypai/.codex/skills/wferp-report/report_designs/` + +- [ ] **Step 1: Write failing catalog tests** + +```python +def test_design_catalog_requires_all_product_metadata(repo_root): + catalog = load_report_design_catalog(repo_root / "report_designs") + financial = catalog.get_profile("financial-control") + assert financial["label"] == "財務控制" + assert financial["best_for"] + assert financial["required_sections"] + assert financial["default_components"] + assert financial["chart_policy"] + assert financial["table_policy"] + assert financial["kpi_policy"] + assert financial["tone"] + assert financial["layout_density"] + assert financial["validator_focus"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/skill_scripts/test_report_catalog.py -v +``` + +Expected: FAIL because `index.json` or required metadata validation is incomplete. + +- [ ] **Step 3: Implement `index.json`** + +Required IDs: + +```json +[ + "financial-control", + "executive-summary", + "detail-ledger", + "exception-audit", + "operations-review", + "trend-briefing" +] +``` + +Every profile entry must define: + +```json +{ + "id": "financial-control", + "label": "財務控制", + "best_for": ["費用分析", "預算差異", "異常控管"], + "required_sections": ["executive-summary", "kpi-overview", "trend", "detail-table", "recommendations"], + "default_components": ["KpiGrid", "ChartBlock", "DataTable", "InsightBlock", "RecommendationList"], + "chart_policy": {"preferred": ["bar", "stacked-bar", "combo"], "avoid": ["pie"]}, + "table_policy": {"density": "compact", "summary_rows": true, "conditional_formatting": true}, + "kpi_policy": {"include_variance": true, "include_budget_ratio": true}, + "tone": "管理控制、明確、可追責", + "layout_density": "dense", + "validator_focus": ["aggregate_consistency", "variance_explanation", "exception_visibility"] +} +``` + +- [ ] **Step 4: Enforce selected design in runtime** + +`skill_scripts/report_catalog.py` must: + +- read `index.json` +- reject missing profile files +- reject profile IDs not listed in index +- reject profiles missing any required key +- expose selected design defaults for report scaffold + +- [ ] **Step 5: Run tests** + +```bash +pytest tests/skill_scripts/test_report_catalog.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add report_designs skill_scripts/report_catalog.py tests/skill_scripts/test_report_catalog.py +git commit -m "feat: enforce report design catalog metadata" +``` + +Local mirrored catalog is outside repo; include it in evidence packet. + +--- + +## Task 8: Validator Protocol And Minimal Repair Policy + +**Files:** +- Modify: `skill_scripts/validator_contracts.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `tests/skill_scripts/test_validator_contracts.py` +- Modify: `tests/skill_scripts/test_report_harness.py` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/validators.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/repair-policy.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/review-checklist.md` + +- [ ] **Step 1: Write failing validator tests** + +```python +def test_validator_result_requires_evidence_and_repair_fields(): + result = ValidatorResult( + role="sql_safety_reviewer", + status="fail", + evidence=[{"command": "python3 -m skill_scripts.cli_report_harness validate-sql"}], + findings=["SELECT INTO is blocked"], + requiredFixes=["Remove SELECT INTO"], + residualRisks=[], + ) + assert result.to_dict()["status"] == "fail" + + +def test_success_is_blocked_when_any_validator_fails(tmp_path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection({"selected_report_type": "管理摘要", "selected_report_design": "financial-control"}) + harness.confirm("report_selection", "產生報告") + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review({ + "validator_results": [ + {"role": "visual_taste_reviewer", "status": "fail", "evidence": [], "findings": ["文字重疊"], "requiredFixes": ["修正表格寬度"], "residualRisks": []} + ] + }) + assert harness.can_deliver()["allowed"] is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/skill_scripts/test_validator_contracts.py tests/skill_scripts/test_report_harness.py -v +``` + +Expected: FAIL because final delivery gate does not aggregate validator failures. + +- [ ] **Step 3: Implement validator roles** + +Required roles: + +```python +VALIDATOR_ROLES = { + "source_requirement_reviewer", + "excel_formula_reviewer", + "sql_safety_reviewer", + "schema_relationship_reviewer", + "data_preview_reviewer", + "report_content_reviewer", + "visual_taste_reviewer", + "data_visualization_reviewer", + "react_technical_reviewer", +} +``` + +- [ ] **Step 4: Implement delivery gate** + +`ReportHarness.can_deliver()` returns: + +```python +{ + "allowed": False, + "blocking_validators": ["visual_taste_reviewer"], + "accepted_residual_risks": [], +} +``` + +Only allow delivery when all validators pass or user confirmation explicitly accepts residual risk at final checkpoint. + +- [ ] **Step 5: Implement repair log append** + +`ReportHarness.append_repair_log()` writes `review/repair-log.md` entries: + +```text +## 2026-06-18 sql_safety_reviewer + +Failure: +Scope: +Minimal vertical slice: +Files changed: +Validation rerun: +Residual risk: +``` + +- [ ] **Step 6: Run tests** + +```bash +pytest tests/skill_scripts/test_validator_contracts.py tests/skill_scripts/test_report_harness.py -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add skill_scripts/validator_contracts.py skill_scripts/report_harness.py tests/skill_scripts/test_validator_contracts.py tests/skill_scripts/test_report_harness.py +git commit -m "feat: enforce report validators and repair gates" +``` + +Local reference docs are outside repo; include them in evidence packet. + +--- + +## Task 9: SQLite-First And PostgreSQL Formal E2E Acceptance + +**Files:** +- Modify: `tests/skill_scripts/expense_report_fixture.py` +- Modify: `tests/skill_scripts/test_expense_analysis_sqlite_e2e.py` +- Modify: `tests/skill_scripts/test_expense_analysis_postgres_e2e.py` +- Modify: `scripts/run_expense_analysis_sqlite_e2e.sh` +- Modify: `scripts/run_expense_analysis_postgres_e2e.sh` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/e2e-expense-analysis.md` + +- [ ] **Step 1: Write failing quantitative E2E assertions** + +SQLite-first acceptance: + +```python +def test_expense_analysis_sqlite_e2e_quantitative_acceptance(tmp_path): + result = run_expense_analysis_sqlite_e2e(tmp_path) + assert result["row_count"] == 6 + assert result["columns"] == ["department_code", "department_name", "expense_subject", "amount", "budget_amount", "variance_amount", "expense_ratio"] + assert result["aggregates"]["total_amount"] == 120000 + assert result["aggregates"]["total_budget"] == 100000 + assert result["aggregates"]["variance_amount"] == 20000 + assert result["aggregates"]["max_expense_ratio"] == 0.35 + assert result["excluded_rows"]["non_2026"] == 1 + assert result["excluded_rows"]["non_expense_account"] == 1 + assert result["sql_safety"]["readonly"] is True + assert result["sql_safety"]["blocked_keywords"] == [] +``` + +PostgreSQL formal substitute acceptance must run the same semantic query against Docker PostgreSQL and assert the same aggregates. + +- [ ] **Step 2: Run SQLite test to verify it fails** + +```bash +pytest tests/skill_scripts/test_expense_analysis_sqlite_e2e.py -v +``` + +Expected: FAIL if any required quantitative assertion is missing or incorrect. + +- [ ] **Step 3: Implement deterministic fixture data** + +Fixture rows: + +```text +2026 行政部 旅費 35000 budget 30000 +2026 行政部 文具 10000 budget 8000 +2026 研發部 雲端服務 30000 budget 25000 +2026 研發部 軟體訂閱 25000 budget 22000 +2026 業務部 交際費 12000 budget 10000 +2026 業務部 廣告費 8000 budget 5000 +2025 行政部 旅費 9000 budget 9000 +2026 行政部 資產購置 50000 budget 50000 +``` + +Expected included amount: `120000`. Exclude `2025` and `資產購置`. + +- [ ] **Step 4: Run SQLite E2E** + +```bash +bash scripts/run_expense_analysis_sqlite_e2e.sh +``` + +Expected: + +```text +sqlite_expense_e2e=pass row_count=6 total_amount=120000 total_budget=100000 variance_amount=20000 max_expense_ratio=0.35 +``` + +- [ ] **Step 5: Run PostgreSQL E2E** + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +``` + +Expected: + +```text +postgres_expense_e2e=pass row_count=6 total_amount=120000 total_budget=100000 variance_amount=20000 max_expense_ratio=0.35 +``` + +- [ ] **Step 6: Run both pytest files** + +```bash +pytest tests/skill_scripts/test_expense_analysis_sqlite_e2e.py tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v +``` + +Expected: PASS. These are real E2E tests with real local DB setup and real SQL execution. + +- [ ] **Step 7: Commit** + +```bash +git add tests/skill_scripts/expense_report_fixture.py tests/skill_scripts/test_expense_analysis_sqlite_e2e.py tests/skill_scripts/test_expense_analysis_postgres_e2e.py scripts/run_expense_analysis_sqlite_e2e.sh scripts/run_expense_analysis_postgres_e2e.sh +git commit -m "test: enforce expense report e2e acceptance" +``` + +Local E2E reference doc is outside repo; include it in evidence packet. + +--- + +## Task 10: Full Flow Wiring And Final Verification + +**Files:** +- Modify: `skill_scripts/cli_report_harness.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `report_renderer/README.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/SKILL.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/README.md` +- Modify: `/home/timmypai/.codex/skills/wferp-report/references/harness.md` + +- [ ] **Step 1: Write failing CLI flow test** + +Add a CLI test that executes the local flow through files: + +```python +import json +import subprocess +import sys + + +def run_cli(args, expect_code=0): + completed = subprocess.run( + [sys.executable, "-m", "skill_scripts.cli_report_harness", *args], + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode == expect_code, completed.stderr + if expect_code == 0: + return json.loads(completed.stdout) + return {"stdout": completed.stdout, "stderr": completed.stderr, "returncode": completed.returncode} + + +def test_cli_full_flow_blocks_and_advances_by_confirmation(tmp_path): + run_root = tmp_path / "runs" + result = run_cli(["create-run", "--run-root", str(run_root), "--run-id", "run-001", "--prompt", "查詢費用分析"]) + assert result["status"] == "created" + + sql_result = run_cli(["write-sql-review", "--run-dir", str(run_root / "run-001"), "--sql", "SELECT department, amount FROM expenses"]) + assert sql_result["checkpoint"] == "sql_review" + + blocked = run_cli(["write-data-preview", "--run-dir", str(run_root / "run-001"), "--payload", "{}"], expect_code=2) + assert "SQL must be confirmed" in blocked["stderr"] + + confirm = run_cli(["confirm", "--run-dir", str(run_root / "run-001"), "--checkpoint", "sql_review", "--action", "同意查詢"]) + assert confirm["status"] == "confirmed" +``` + +- [ ] **Step 2: Run flow tests to verify failure** + +```bash +pytest tests/skill_scripts/test_cli_report_harness.py -v +``` + +Expected: FAIL where CLI commands are missing or not enforcing confirmation files. + +- [ ] **Step 3: Wire CLI commands** + +Required commands: + +```text +create-run +write-excel-confirmation +write-sql-review +confirm +write-data-preview +write-report-selection +scaffold-report +write-final-review +can-deliver +serve-checkpoint +``` + +Each command outputs JSON and returns non-zero on blocked gates. + +- [ ] **Step 4: Update user-facing docs** + +Update local `SKILL.md` and `references/harness.md` so executing the skill means executing this harness: + +```text +1. 建立 run +2. 解析 prompt/Excel +3. 開啟欄位與公式 checkpoint +4. 生成 SQL review checkpoint +5. 使用者確認後查詢 DB +6. 開啟 data preview checkpoint +7. 開啟 report selection checkpoint +8. scaffold final React report +9. 執行 validators +10. repair 或 delivery +``` + +- [ ] **Step 5: Run complete verification** + +```bash +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +pytest tests/skill_scripts/test_checkpoint_companion.py tests/skill_scripts/test_report_harness.py tests/skill_scripts/test_report_scaffold.py tests/skill_scripts/test_validator_contracts.py tests/skill_scripts/test_cli_report_harness.py -v +pytest tests/skill_scripts/test_expense_analysis_sqlite_e2e.py -v +bash scripts/run_expense_analysis_postgres_e2e.sh +cd report_renderer && npm test && npm run build +bash /home/timmypai/.codex/skills/wferp-report/scripts/validate-skill.sh +``` + +Expected: + +- all pytest targets PASS +- PostgreSQL E2E prints `postgres_expense_e2e=pass` +- Vitest PASS +- Vite build completes +- skill validator prints `wferp-report skill validation passed` + +- [ ] **Step 6: Commit** + +```bash +git add skill_scripts/cli_report_harness.py skill_scripts/report_harness.py report_renderer/README.md tests/skill_scripts/test_cli_report_harness.py +git commit -m "feat: wire wferp report harness flow" +``` + +Local skill docs are outside repo; include them in evidence packet. + +--- + +## Subagent Evidence Packet + +Every subagent must return this packet: + +```json +{ + "task": "Task N name", + "status": "pass", + "files_changed": [], + "outside_repo_files_changed": [], + "tests_failed_first": [ + {"command": "pytest ...", "expected_failure": "module not found or assertion failure"} + ], + "tests_passed_after": [ + {"command": "pytest ...", "evidence": "PASS"} + ], + "quantitative_acceptance": {}, + "validator_evidence": [], + "commits": [], + "residual_risks": [] +} +``` + +Main agent review requirements: + +- reject a packet without failing-test evidence +- reject a packet that uses non-real E2E instead of SQLite/PostgreSQL real execution +- reject a packet that stages unrelated worktree changes +- reject a packet that changes confirmed Excel fields without returning to the field/formula checkpoint +- reject a packet that delivers report output while validators are failing + +## Final Acceptance Criteria + +- Local skill has full beautiful-article-style chapter mapping and progressive disclosure references. +- Checkpoint companion opens a local URL, posts confirmation to harness, writes confirmation JSON and audit JSONL. +- Management view is understandable without reading SQL; technical view exposes SQL/schema/validator evidence. +- Excel intake captures DB fields, user formula fields, formula dependencies, and desired output report links. +- Final report scaffold creates one section per file and uses approved component protocol. +- `RawBlock` is documented and constrained to readonly props and no browser/DB/SQL side effects. +- ChartBlock supports bar, stacked bar, line, area, pie/donut, and combo. +- DataTable supports sorting, filtering, show/hide columns, summary rows, conditional formatting, full-table search, CSV export, and pagination. +- Report design catalog includes `index.json` and six product profiles. +- Validators block delivery on failures unless user explicitly accepts residual risk. +- Repair log records minimal vertical slice scope. +- SQLite-first E2E passes with `row_count=6`, `total_amount=120000`, `total_budget=100000`, `variance_amount=20000`, `max_expense_ratio=0.35`. +- Docker PostgreSQL formal E2E passes with the same quantitative acceptance. +- React test and build pass. +- Local skill structure validator passes. diff --git a/docs/superpowers/plans/2026-06-18-wferp-report-skill-and-react-renderer.md b/docs/superpowers/plans/2026-06-18-wferp-report-skill-and-react-renderer.md new file mode 100644 index 0000000..3c9f0e2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-wferp-report-skill-and-react-renderer.md @@ -0,0 +1,1270 @@ +# WFERP Report Skill And React Renderer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to execute this plan step-by-step. + +本計畫把 `docs/superpowers/specs/2026-06-18-wferp-report-skill-and-react-renderer-design.md` 轉成可執行工作。所有使用者可見文件、skill 文案、互動頁面文字、驗收紀錄使用繁體中文。程式、測試名稱、commit message 可使用英文。 + +## Goal + +建立本地端 `wferp-report` Codex skill 與 repo 內支援工具,使使用者能在 Codex 以自然語言與上傳欄位/Excel 需求檔啟動流程,skill 依 DB 設定與 WFERP schema/relationship 生成合法唯讀 `SELECT` SQL,先本地驗證語法與禁止語法,再查詢真實資料庫,呈現資料預覽供使用者確認,接著讓使用者選擇報告格式與設計樣式,用 React renderer 產生互動確認頁與最終報告。 + +## Constraints + +- 不把 local skill 放進 worktree;skill 目標路徑是 `/home/timmypai/.codex/skills/wferp-report/`。實作時寫入此路徑需要依 sandbox 規則請求 escalation。 +- 保留 schema 重建功能:`_Source/1_mssql_to_json.py` 與 `_Source/2_FieldNameConvert2utf8.py` 不隔離;DB 連線設定不可硬編碼在程式內。 +- 隔離不再使用的 static doc artifacts:root `index.html`、`df_style.css`、`HTML/`,以及只用於舊 static HTML/SQL 輸出的 `_Source/3_CreateIndexHtml.py`、`_Source/4_CreateTableStructureHtml.py`、`_Source/5_CreateTableStructureSQL.py`。 +- 保留 `_Source/*.json` schema metadata、relationship、`skill_scripts/`、`tests/skill_scripts/`、`test_db/` 作為 SQL 生成與驗證基礎。 +- E2E 測試必須使用 Docker 內 PostgreSQL 當成 MS-SQL substitute,建立真實測試 schema 與資料,執行真實 SQL,不使用 fake、smoke、mock。 +- SQL 執行只允許唯讀 `SELECT`;任何 `INSERT`、`UPDATE`、`DELETE`、`DROP`、`ALTER`、`EXEC`、`MERGE`、`TRUNCATE`、`xp_` 類語法必須被阻擋。 +- 報告 HTML 由 React renderer 產生,不再依賴舊 `index.html` 或 `HTML/*.html`。 + +## Desired End State + +- `_Source/1_mssql_to_json.py` 從 `WFERP_SCHEMA_DB_*` 環境變數讀取 schema rebuild DB 設定。 +- `skill_scripts/cli_generate_select.py` 或新增 harness entrypoint 可支援費用分析類需求,產出 WFERP schema 對應 SQL,並可在本地 PostgreSQL E2E fixture 上執行驗證。 +- 新增 Excel intake parser,可解析使用者上傳 workbook 中的需求欄位、使用者公式欄位、公式 lineage、輸出報表 sheet 結構。 +- 新增 report harness state schema 與 checkpoint payload,讓 skill 能生成互動 HTML 給使用者確認:需求欄位/公式、SQL、資料預覽、報告格式、報告初稿、最終審核。 +- 新增 React renderer project,使用 React 產生 checkpoint/final report HTML,可套用 beautiful-article/reacticle 作法與 report design repository。 +- `/home/timmypai/.codex/skills/wferp-report/` 存在可用的 local Codex skill:`SKILL.md`、references、report designs、validator prompts、harness instructions。 +- 新增一個費用分析本地端 E2E:Docker PostgreSQL 建資料、seed deterministic data、產 SQL、轉譯安全子集、執行查詢、驗證 rows/columns/aggregates/percentages/exclusions。 + +## Implementation Strategy + +先做可量化、可測試的核心路徑,再補上 skill 與 React 互動層: + +1. DB config externalization 與舊 artifacts 隔離是低風險基礎整理。 +2. 費用分析 SQL 生成與 PostgreSQL E2E 是端到端驗收核心。 +3. Excel intake 與 report harness state 讓 skill 能理解上傳檔案與公式需求。 +4. React renderer 與 local skill 檔案最後接上全流程互動。 + +每一組工作都先寫 failing test,再實作,再跑對應測試。每個 major milestone 建議獨立 commit。 + +## Task 1: Externalize Schema Rebuild DB Config + +### Files + +- Add: `_Source/schema_db_config.py` +- Modify: `_Source/1_mssql_to_json.py` +- Add: `tests/source/test_schema_db_config.py` +- Modify: `INSTALLATION.md` +- Modify: `OPERATIONS.md` + +### Tests First + +新增 `tests/source/test_schema_db_config.py`,用 `importlib.util.spec_from_file_location` 載入 `_Source/schema_db_config.py`,避免 `_Source` 不是 package 的問題。 + +Test cases: + +- `test_schema_db_config_reads_required_env` +- `test_schema_db_config_defaults_port_and_database` +- `test_schema_db_config_rejects_missing_host_username_password` +- `test_schema_db_config_redacts_password_for_logging` + +Expected initial command: + +```bash +pytest tests/source/test_schema_db_config.py -v +``` + +Expected initial result: fail because `_Source/schema_db_config.py` does not exist. + +### Implementation + +`_Source/schema_db_config.py` 提供: + +```python +from dataclasses import dataclass +import os + + +class SchemaDbConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class SchemaDbConfig: + host: str + port: int + database: str + username: str + password: str + + @classmethod + def from_env(cls, environ=None): + env = os.environ if environ is None else environ + missing = [ + name + for name in ("WFERP_SCHEMA_DB_HOST", "WFERP_SCHEMA_DB_USERNAME", "WFERP_SCHEMA_DB_PASSWORD") + if not env.get(name) + ] + if missing: + raise SchemaDbConfigError("Missing schema DB env vars: " + ", ".join(missing)) + return cls( + host=env["WFERP_SCHEMA_DB_HOST"], + port=int(env.get("WFERP_SCHEMA_DB_PORT", "1433")), + database=env.get("WFERP_SCHEMA_DB_DATABASE", "DSCSYS"), + username=env["WFERP_SCHEMA_DB_USERNAME"], + password=env["WFERP_SCHEMA_DB_PASSWORD"], + ) + + def redacted(self): + return { + "host": self.host, + "port": self.port, + "database": self.database, + "username": self.username, + "password": "***", + } +``` + +`_Source/1_mssql_to_json.py` 移除 `SERVER_IP`、`USERNAME`、`PASSWORD`、`DATABASE` 硬編碼,改用: + +```python +from schema_db_config import SchemaDbConfig + +config = SchemaDbConfig.from_env() +conn = pymssql.connect( + server=config.host, + port=config.port, + user=config.username, + password=config.password, + database=config.database, + charset="utf8", +) +``` + +### Verification + +```bash +pytest tests/source/test_schema_db_config.py -v +python3 -m py_compile _Source/schema_db_config.py _Source/1_mssql_to_json.py +rg "SERVER_IP|USERNAME|PASSWORD|DATABASE =" _Source/1_mssql_to_json.py +``` + +Expected: + +- pytest passes. +- py_compile passes. +- `rg` returns no old credential constants in `_Source/1_mssql_to_json.py`. + +## Task 2: Quarantine Legacy Static HTML Artifacts + +### Files + +- Add: `scripts/quarantine_legacy_static_docs.py` +- Add: `tests/scripts/test_quarantine_legacy_static_docs.py` +- Runtime output after executing script once: + - `legacy_static_docs/index.html` + - `legacy_static_docs/df_style.css` + - `legacy_static_docs/HTML/` + - `legacy_static_docs/_Source/3_CreateIndexHtml.py` + - `legacy_static_docs/_Source/4_CreateTableStructureHtml.py` + - `legacy_static_docs/_Source/5_CreateTableStructureSQL.py` + - `legacy_static_docs/manifest.json` + +### Tests First + +Test cases: + +- `test_quarantine_dry_run_lists_expected_paths` +- `test_quarantine_moves_expected_paths_and_writes_manifest` +- `test_quarantine_is_idempotent_when_sources_already_moved` + +Use `tmp_path` and copy tiny fixture files into a fake repo root. Do not operate on real repo in unit test. + +Expected initial command: + +```bash +pytest tests/scripts/test_quarantine_legacy_static_docs.py -v +``` + +Expected initial result: fail because script does not exist. + +### Implementation + +Script contract: + +```bash +python3 scripts/quarantine_legacy_static_docs.py --repo-root /home/timmypai/.codex/worktrees/5f5b/wferp --dry-run +python3 scripts/quarantine_legacy_static_docs.py --repo-root /home/timmypai/.codex/worktrees/5f5b/wferp +``` + +Behavior: + +- Create `legacy_static_docs/`. +- Move only listed legacy paths. +- Preserve relative path under quarantine. +- Write manifest with source path, destination path, moved_at ISO timestamp, reason. +- If a source path is already absent and destination exists, record `already_quarantined`. +- Never move `_Source/*.json`, `_Source/1_mssql_to_json.py`, `_Source/2_FieldNameConvert2utf8.py`, `skill_scripts/`, `test_db/`, `tests/`. + +### Verification + +```bash +pytest tests/scripts/test_quarantine_legacy_static_docs.py -v +python3 scripts/quarantine_legacy_static_docs.py --repo-root /home/timmypai/.codex/worktrees/5f5b/wferp --dry-run +python3 scripts/quarantine_legacy_static_docs.py --repo-root /home/timmypai/.codex/worktrees/5f5b/wferp +test -f legacy_static_docs/index.html +test -d legacy_static_docs/HTML +test -f legacy_static_docs/_Source/3_CreateIndexHtml.py +test -f _Source/1_mssql_to_json.py +test -f _Source/2_FieldNameConvert2utf8.py +``` + +## Task 3: Add Expense Analysis SQL Generation Path + +### Files + +- Modify: `skill_scripts/sql_generator.py` +- Modify or add tests in: `tests/skill_scripts/test_sql_generator.py` +- Modify if necessary: `skill_scripts/prompt_sql_consistency.py` +- Modify if necessary: `tests/skill_scripts/test_prompt_sql_consistency.py` + +### Tests First + +新增 deterministic tests,不能使用 `LLM_MOCK_RESPONSE`。 + +Test cases: + +- `test_generates_expense_analysis_sql_for_2026_q1` +- `test_expense_analysis_sql_uses_acpta_acptb_join` +- `test_expense_analysis_sql_excludes_void_and_unconfirmed_vouchers` +- `test_expense_analysis_sql_is_sql_server_2000_compatible` +- `test_expense_analysis_sql_uses_required_amount_fields` + +Expected checks: + +```python +assert "FROM [DSCSYS].[dbo].[ACPTA]" in sql +assert "JOIN [DSCSYS].[dbo].[ACPTB]" in sql +assert "TA001" in sql and "TA002" in sql +assert "TB001" in sql and "TB002" in sql +assert "TB014" in sql +assert "TB013" in sql +assert "SUM" in sql +assert "TB017" in sql +assert "TB018" in sql +assert "TA003 >= '20260101'" in sql +assert "TA003 <= '20260331'" in sql +assert "TA018 <> 'Y'" in sql +assert "TA024 = 'Y'" in sql +assert "WITH " not in sql.upper() +assert "OVER (" not in sql.upper() +``` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_sql_generator.py -v +``` + +Expected initial result: fail because generator does not yet have expense-specific SQL. + +### Implementation + +Add a narrow deterministic branch in `generate_select_sql`: + +- Trigger keywords: `費用`, `費用分析`, `部門費用`, `科目費用`, `Q1`, `第一季`, `2026`. +- Tables: + - `ACPTA` header: voucher type/number/date/status. + - `ACPTB` body: department/account/amount/tax. +- SQL Server target remains bracketed identifiers. +- Do not use modern SQL Server syntax. Use derived subquery for grand total percentage instead of window functions. + +SQL shape: + +```sql +SELECT + detail.TB014 AS expense_department, + detail.TB013 AS expense_account, + SUM(detail.TB017) AS untaxed_amount, + SUM(detail.TB018) AS tax_amount, + SUM(detail.TB017 + detail.TB018) AS total_amount, + CAST( + CASE + WHEN grand.grand_total_amount = 0 THEN 0 + ELSE SUM(detail.TB017 + detail.TB018) * 100.0 / grand.grand_total_amount + END AS decimal(18, 4) + ) AS total_amount_pct +FROM [DSCSYS].[dbo].[ACPTA] header +JOIN [DSCSYS].[dbo].[ACPTB] detail + ON header.TA001 = detail.TB001 + AND header.TA002 = detail.TB002 +CROSS JOIN ( + SELECT SUM(grand_detail.TB017 + grand_detail.TB018) AS grand_total_amount + FROM [DSCSYS].[dbo].[ACPTA] grand_header + JOIN [DSCSYS].[dbo].[ACPTB] grand_detail + ON grand_header.TA001 = grand_detail.TB001 + AND grand_header.TA002 = grand_detail.TB002 + WHERE grand_header.TA003 >= '20260101' + AND grand_header.TA003 <= '20260331' + AND ISNULL(grand_header.TA018, '') <> 'Y' + AND grand_header.TA024 = 'Y' +) grand +WHERE header.TA003 >= '20260101' + AND header.TA003 <= '20260331' + AND ISNULL(header.TA018, '') <> 'Y' + AND header.TA024 = 'Y' +GROUP BY detail.TB014, detail.TB013, grand.grand_total_amount +ORDER BY total_amount DESC +``` + +If existing SQL validator rejects aliases in `ORDER BY`, order by aggregate expression instead: + +```sql +ORDER BY SUM(detail.TB017 + detail.TB018) DESC +``` + +### Verification + +```bash +pytest tests/skill_scripts/test_sql_generator.py -v +pytest tests/skill_scripts/test_prompt_sql_consistency.py -v +python3 -m skill_scripts.cli_generate_select --prompt "請產出2026第一季費用分析,依部門與會計科目彙總未稅金額、稅額、總額與占比" --mode rule +``` + +Expected: + +- Unit tests pass. +- CLI prints SQL containing `ACPTA`, `ACPTB`, `TB014`, `TB013`, `TB017`, `TB018`. + +## Task 4: Add PostgreSQL Readonly Adapter For Local E2E + +### Files + +- Add: `skill_scripts/postgres_readonly_adapter.py` +- Add: `tests/skill_scripts/test_postgres_readonly_adapter.py` + +### Tests First + +The adapter is only for test execution of a verified read-only SQL Server subset against PostgreSQL. It must not be used to make unsafe SQL acceptable. + +Test cases: + +- `test_translates_bracketed_schema_identifiers` +- `test_translates_top_to_limit` +- `test_translates_isnull_to_coalesce` +- `test_preserves_readonly_select_semantics` +- `test_rejects_non_select_before_translation` +- `test_rejects_semicolon_chained_statements` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_postgres_readonly_adapter.py -v +``` + +Expected initial result: fail because adapter does not exist. + +### Implementation + +Expose: + +```python +class PostgresReadonlyAdapterError(ValueError): + pass + + +def translate_sqlserver_select_to_postgres(sql: str) -> str: + ... +``` + +Supported transforms: + +- `[DSCSYS].[dbo].[ACPTA]` -> `dbo."ACPTA"` +- `[DSCSYS].[dbo].[ACPTB]` -> `dbo."ACPTB"` +- `[Column]` -> `"Column"` for simple identifiers if generated SQL includes column brackets. +- `ISNULL(` -> `COALESCE(` +- `SELECT TOP 20 ...` -> `SELECT ... LIMIT 20` +- SQL Server `decimal(18, 4)` remains accepted by PostgreSQL as `decimal(18, 4)`. + +Safety checks before and after translation: + +- Exactly one statement. +- Must start with `SELECT`. +- Reject blocked keywords with word boundaries: `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `EXEC`, `MERGE`, `TRUNCATE`, `CREATE`, `GRANT`, `REVOKE`. +- Reject comments `--`, `/*`. + +### Verification + +```bash +pytest tests/skill_scripts/test_postgres_readonly_adapter.py -v +python3 -m py_compile skill_scripts/postgres_readonly_adapter.py +``` + +## Task 5: Add PostgreSQL E2E Test Database Fixture + +### Files + +- Add: `test_db/docker-compose.postgres-e2e.yml` +- Add: `test_db/postgres_e2e/01_create_expense_fixture.sql` +- Add: `test_db/postgres_e2e/README.md` + +### Fixture Contract + +Use PostgreSQL service name `wferp-postgres-e2e`, database `wferp_e2e`, user `wferp`, password `wferp_pass`. + +DDL: + +```sql +CREATE SCHEMA IF NOT EXISTS dbo; + +CREATE TABLE dbo."ACPTA" ( + "TA001" varchar(4) NOT NULL, + "TA002" varchar(20) NOT NULL, + "TA003" varchar(8) NOT NULL, + "TA018" varchar(1), + "TA024" varchar(1) NOT NULL, + PRIMARY KEY ("TA001", "TA002") +); + +CREATE TABLE dbo."ACPTB" ( + "TB001" varchar(4) NOT NULL, + "TB002" varchar(20) NOT NULL, + "TB003" varchar(4) NOT NULL, + "TB013" varchar(20) NOT NULL, + "TB014" varchar(20) NOT NULL, + "TB017" numeric(18, 2) NOT NULL, + "TB018" numeric(18, 2) NOT NULL, + PRIMARY KEY ("TB001", "TB002", "TB003"), + FOREIGN KEY ("TB001", "TB002") REFERENCES dbo."ACPTA" ("TA001", "TA002") +); +``` + +Seed expected included rows: + +| Department | Account | Untaxed | Tax | Total | +| --- | --- | ---: | ---: | ---: | +| D001 | 6101 | 22000.00 | 1100.00 | 23100.00 | +| D001 | 6201 | 15000.00 | 750.00 | 15750.00 | +| D002 | 6101 | 24000.00 | 1200.00 | 25200.00 | +| D002 | 6201 | 36000.00 | 1800.00 | 37800.00 | +| D003 | 6101 | 24000.00 | 1200.00 | 25200.00 | +| D003 | 6201 | 9000.00 | 450.00 | 9450.00 | + +Grand totals: + +- included detail rows: `12` +- grouped rows: `6` +- untaxed total: `130000.00` +- tax total: `6500.00` +- total amount: `136500.00` +- department totals: + - D001: `38850.00` + - D002: `63000.00` + - D003: `34650.00` +- percentage total tolerance: `99.9999 <= sum(total_amount_pct) <= 100.0001` + +Excluded control rows: + +- one 2025 row with total `999999.00`, excluded by date. +- one 2026 Q1 row with `TA024 = 'N'`, excluded by confirmation status. +- one 2026 Q1 row with `TA018 = 'Y'`, excluded by void status. + +### Verification + +```bash +docker compose -f test_db/docker-compose.postgres-e2e.yml up -d +docker exec -i wferp-postgres-e2e psql -U wferp -d wferp_e2e -f /init/01_create_expense_fixture.sql +docker exec -i wferp-postgres-e2e psql -U wferp -d wferp_e2e -c "SELECT COUNT(*) FROM dbo.\"ACPTA\";" +docker exec -i wferp-postgres-e2e psql -U wferp -d wferp_e2e -c "SELECT COUNT(*) FROM dbo.\"ACPTB\";" +``` + +Expected: + +- ACPTA count includes approved and excluded voucher headers. +- ACPTB count includes 12 included detail rows plus excluded control rows. + +## Task 6: Add Real Expense Analysis E2E Test + +### Files + +- Add: `tests/skill_scripts/test_expense_analysis_postgres_e2e.py` +- Add: `scripts/run_expense_analysis_postgres_e2e.sh` +- Modify: `INSTALLATION.md` +- Modify: `OPERATIONS.md` + +### Tests First + +Use real generator and real database. Do not use LLM response fixtures. The test may skip with an explicit message if `WFERP_RUN_POSTGRES_E2E=1` is not set, but the runner script must set it and run the full path. + +Test cases: + +- `test_expense_analysis_query_executes_against_postgres_fixture` +- `test_expense_analysis_rows_columns_and_totals_match_fixture` +- `test_expense_analysis_excluded_rows_are_not_counted` +- `test_expense_analysis_percentage_sum_is_approximately_100` +- `test_expense_analysis_query_contains_no_blocked_sql` + +Expected initial command after adding test: + +```bash +WFERP_RUN_POSTGRES_E2E=1 pytest tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v +``` + +Expected initial result: fail until Tasks 3-5 are complete and PostgreSQL dependency/client path exists. + +### Implementation + +Add test dependency path: + +- Prefer `psycopg` if available. +- If repo keeps manual dependencies only, document: + +```bash +python3 -m pip install psycopg[binary] +``` + +Test structure: + +```python +def test_expense_analysis_rows_columns_and_totals_match_fixture(): + sql = generate_select_sql( + prompt="請產出2026第一季費用分析,依部門與會計科目彙總未稅金額、稅額、總額與占比", + mode="rule", + ) + translated = translate_sqlserver_select_to_postgres(sql) + rows = execute_postgres(translated) + + assert len(rows) == 6 + assert set(rows[0].keys()) == { + "expense_department", + "expense_account", + "untaxed_amount", + "tax_amount", + "total_amount", + "total_amount_pct", + } + assert_decimal_sum(rows, "untaxed_amount", "130000.00") + assert_decimal_sum(rows, "tax_amount", "6500.00") + assert_decimal_sum(rows, "total_amount", "136500.00") +``` + +Runner script: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f test_db/docker-compose.postgres-e2e.yml up -d +docker exec -i wferp-postgres-e2e psql -U wferp -d wferp_e2e -f /init/01_create_expense_fixture.sql +export WFERP_RUN_POSTGRES_E2E=1 +export WFERP_POSTGRES_E2E_DSN="postgresql://wferp:wferp_pass@127.0.0.1:55432/wferp_e2e" +pytest tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v +``` + +### Quantitative Acceptance + +Pass criteria: + +- Generated SQL starts with `SELECT`. +- Blocked keyword count is `0`. +- Query returns exactly `6` grouped rows. +- Returned columns exactly match expected column set. +- Untaxed total equals `130000.00`. +- Tax total equals `6500.00`. +- Total amount equals `136500.00`. +- D002/6201 is top row by `total_amount` with `37800.00`. +- Excluded totals `999999.00`, `7777.00`, `8888.00` do not appear in any aggregate. +- Sum of `total_amount_pct` is within `0.0001` of `100.0000`. + +### Verification + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +pytest tests/skill_scripts/test_postgres_readonly_adapter.py -v +pytest tests/skill_scripts/test_sql_generator.py -v +``` + +## Task 7: Add Excel Requirement Intake And Formula Lineage Parser + +### Files + +- Add: `skill_scripts/excel_intake.py` +- Add: `tests/skill_scripts/test_excel_intake.py` +- Add fixtures: + - `tests/fixtures/expense_analysis_requirement.xlsx` + - or generated inside test using `openpyxl` +- Modify: `INSTALLATION.md` + +### Tests First + +Use a real workbook, not CSV-only substitute. The workbook must contain: + +- `需求欄位` sheet: WFERP table/field hints and display names. +- `自訂公式` sheet: user-defined fields with formulas. +- `管理報表` sheet: intended output columns and formulas linking back to requirement fields. + +Test cases: + +- `test_reads_required_database_fields_from_workbook` +- `test_reads_user_formula_fields` +- `test_extracts_formula_references` +- `test_detects_formula_columns_without_database_source` +- `test_builds_confirmation_payload_in_chinese` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_excel_intake.py -v +``` + +Expected initial result: fail because parser does not exist. + +### Implementation + +Add dependency documentation: + +```bash +python3 -m pip install openpyxl +``` + +Expose: + +```python +@dataclass(frozen=True) +class WorkbookRequirement: + database_fields: list[DatabaseFieldRequirement] + formula_fields: list[FormulaFieldRequirement] + report_fields: list[ReportFieldRequirement] + formula_lineage: list[FormulaLineage] + warnings: list[str] + + +def parse_excel_requirement(path: str) -> WorkbookRequirement: + ... + + +def build_excel_confirmation_payload(requirement: WorkbookRequirement) -> dict: + ... +``` + +Formula lineage requirements: + +- Preserve formula text, e.g. `=未稅金額+稅額`. +- Extract referenced display fields. +- Mark references as: + - database-backed + - formula-backed + - unresolved +- Produce Chinese confirmation payload sections: + - `資料庫欄位` + - `使用者公式欄位` + - `報表輸出欄位` + - `需使用者確認` + +### Verification + +```bash +pytest tests/skill_scripts/test_excel_intake.py -v +python3 -m py_compile skill_scripts/excel_intake.py +``` + +## Task 8: Add Report Harness State And Checkpoint Payloads + +### Files + +- Add: `skill_scripts/report_harness_state.py` +- Add: `skill_scripts/report_harness.py` +- Add: `tests/skill_scripts/test_report_harness_state.py` +- Add: `tests/skill_scripts/test_report_harness.py` + +### Tests First + +Test cases: + +- `test_creates_run_directory_with_state_json` +- `test_records_excel_confirmation_checkpoint` +- `test_records_sql_review_checkpoint` +- `test_records_data_preview_checkpoint` +- `test_records_report_selection_checkpoint` +- `test_rejects_state_transition_without_required_confirmation` +- `test_all_checkpoint_payloads_have_chinese_titles_and_actions` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_report_harness_state.py tests/skill_scripts/test_report_harness.py -v +``` + +Expected initial result: fail because harness state does not exist. + +### Implementation + +Run directory shape: + +```text +wferp-report-runs// + state.json + inputs/ + sql/ + data/ + checkpoints/ + 01_excel_confirmation.json + 02_sql_review.json + 03_data_preview.json + 04_report_selection.json + 05_report_draft.json + 06_final_review.json + reports/ +``` + +State schema fields: + +- `run_id` +- `prompt` +- `input_files` +- `schema_snapshot` +- `excel_requirement` +- `sql_candidate` +- `sql_validation` +- `execution_result_summary` +- `report_type` +- `report_design` +- `report_options` +- `validator_results` +- `user_confirmations` + +Checkpoint actions must be explicit: + +- Excel confirmation: `確認欄位與公式`, `要求修正` +- SQL review: `同意查詢`, `調整需求` +- Data preview: `資料正確`, `重新查詢` +- Report selection: `產生報告`, `修改格式` +- Draft review: `接受`, `修正報告` +- Final review: `完成`, `回到初稿` + +### Verification + +```bash +pytest tests/skill_scripts/test_report_harness_state.py tests/skill_scripts/test_report_harness.py -v +python3 -m py_compile skill_scripts/report_harness_state.py skill_scripts/report_harness.py +``` + +## Task 9: Add Report Type Catalog And Design Repository + +### Files In Repo + +- Add: `report_designs/README.md` +- Add: `report_designs/design.md` +- Add: `report_designs/executive-summary.md` +- Add: `report_designs/financial-control.md` +- Add: `report_designs/operations-review.md` +- Add: `report_designs/exception-audit.md` +- Add: `report_designs/trend-briefing.md` +- Add: `report_designs/detail-ledger.md` +- Add: `skill_scripts/report_catalog.py` +- Add: `tests/skill_scripts/test_report_catalog.py` + +### Files In Local Skill + +Mirror the same design repository into: + +- `/home/timmypai/.codex/skills/wferp-report/report_designs/` + +### Tests First + +Test cases: + +- `test_report_catalog_contains_required_report_types` +- `test_report_designs_are_loadable_markdown` +- `test_each_design_declares_required_sections` +- `test_default_options_include_chart_table_analysis_recommendation_flags` + +Required report types: + +- 明細查詢表 +- 彙總統計表 +- 趨勢分析表 +- 比較分析表 +- 異常稽核表 +- 管理摘要 +- 完整分析報告 + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_report_catalog.py -v +``` + +Expected initial result: fail because catalog does not exist. + +### Implementation + +`report_designs/design.md` defines common contract: + +- `id` +- `name` +- `best_for` +- `required_sections` +- `optional_sections` +- `visual_policy` +- `table_policy` +- `analysis_policy` +- `recommendation_policy` +- `react_component_hints` +- `validator_checklist` + +`skill_scripts/report_catalog.py` exposes: + +```python +def list_report_types() -> list[dict]: + ... + + +def list_report_designs(design_dir: str | Path | None = None) -> list[dict]: + ... + + +def build_report_selection_payload() -> dict: + ... +``` + +### Verification + +```bash +pytest tests/skill_scripts/test_report_catalog.py -v +rg "管理摘要|完整分析報告|financial-control|executive-summary" report_designs skill_scripts +``` + +## Task 10: Add React Renderer Project + +### Files + +- Add: `report_renderer/package.json` +- Add: `report_renderer/index.html` +- Add: `report_renderer/src/main.tsx` +- Add: `report_renderer/src/App.tsx` +- Add: `report_renderer/src/components/CheckpointPage.tsx` +- Add: `report_renderer/src/components/ReportPage.tsx` +- Add: `report_renderer/src/components/DataPreviewTable.tsx` +- Add: `report_renderer/src/components/ReportOptionPanel.tsx` +- Add: `report_renderer/src/styles.css` +- Add: `report_renderer/examples/expense-analysis-checkpoint.json` +- Add: `report_renderer/examples/expense-analysis-report.json` +- Add: `report_renderer/tests/renderer.spec.ts` + +### Tests First + +Use Vitest or Playwright depending on installed frontend stack. If adding dependencies is required, ask for network escalation. + +Renderer tests: + +- `renders checkpoint page title in Chinese` +- `renders SQL review payload without executing SQL` +- `renders data preview table with row count` +- `renders report type choices` +- `renders final report sections` +- `does not render legacy iframe or static HTML links` + +Expected initial command: + +```bash +cd report_renderer +npm test +``` + +Expected initial result: fail until renderer exists and dependencies are installed. + +### Implementation + +Use React rendering only. The renderer consumes JSON from `window.__WFERP_REPORT_PAYLOAD__` or `?payload=` during local development. It must not connect to DB. + +Component rules: + +- Checkpoint pages are real interaction surfaces: clear title, data preview, action buttons. +- Final report supports charts/tables/analysis/recommendations based on selected options. +- Use restrained business-report styling, not landing page style. +- Avoid old `index.html` and `HTML/*.html`. +- If beautiful-article/reacticle components are locally available, use their component protocol and theme constraints. If the dependency is not installable, implement a compatible internal component layer and document the deviation in `report_renderer/README.md`. + +### Verification + +```bash +cd report_renderer +npm install +npm test +npm run build +npm run dev -- --host 127.0.0.1 --port 4173 +``` + +Browser QA: + +- Open `http://127.0.0.1:4173`. +- Verify checkpoint example renders. +- Verify final report example renders. +- Verify no console errors. +- Verify desktop and mobile widths do not overlap text/table controls. + +## Task 11: Add Local Skill `/home/timmypai/.codex/skills/wferp-report` + +### Files + +Create outside worktree: + +```text +/home/timmypai/.codex/skills/wferp-report/ + SKILL.md + references/ + harness.md + db-config.md + schema-context.md + excel-intake.md + sql-safety.md + validators.md + react-renderer.md + e2e-expense-analysis.md + report_designs/ + design.md + executive-summary.md + financial-control.md + operations-review.md + exception-audit.md + trend-briefing.md + detail-ledger.md + assets/ + sample-expense-analysis-prompt.md +``` + +### Tests First + +Because this is outside worktree, add repo-side validator script and tests: + +- Add: `scripts/validate_local_wferp_report_skill.py` +- Add: `tests/scripts/test_validate_local_wferp_report_skill.py` + +Test cases: + +- `test_validator_accepts_complete_skill_tree` +- `test_validator_rejects_missing_skill_md` +- `test_validator_requires_harness_sections` +- `test_validator_requires_validator_references` +- `test_validator_requires_report_designs` + +Expected initial command: + +```bash +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +``` + +Expected initial result: fail until validator exists. + +### Implementation + +`SKILL.md` must instruct Codex to: + +1. Intake prompt and uploaded files. +2. Build source inventory. +3. Parse Excel fields/formulas if workbook exists. +4. Generate Excel confirmation HTML. +5. Map requested fields to WFERP schema/relationship. +6. Generate read-only SQL. +7. Validate SQL safety locally. +8. Execute only after user confirmation and DB policy allows. +9. Present data preview HTML. +10. Ask user to choose report type/design/options. +11. Generate report draft HTML via React renderer. +12. Run validators using subagents. +13. Present final report and validation evidence. + +Validator references must define subagent roles: + +- 需求/來源 validator +- Excel 欄位與公式 validator +- SQL 安全 validator +- Schema/relationship validator +- Data preview validator +- 報告內容 validator +- 視覺/技術 validator + +### Verification + +```bash +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +python3 scripts/validate_local_wferp_report_skill.py --skill-root /home/timmypai/.codex/skills/wferp-report +``` + +Expected: + +- Validator passes on local skill tree. + +## Task 12: Wire CLI/Harness Entry Points + +### Files + +- Modify: `skill_scripts/cli_generate_select.py` +- Add: `skill_scripts/cli_report_harness.py` +- Add: `tests/skill_scripts/test_cli_report_harness.py` +- Modify: `OPERATIONS.md` + +### Tests First + +Test cases: + +- `test_cli_report_harness_creates_run_from_prompt_only` +- `test_cli_report_harness_accepts_excel_input_path` +- `test_cli_report_harness_builds_excel_checkpoint` +- `test_cli_report_harness_builds_sql_checkpoint` +- `test_cli_report_harness_does_not_execute_db_without_confirmation_flag` +- `test_cli_report_harness_requires_allow_non_test_db_execution_for_non_test_env` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_cli_report_harness.py -v +``` + +Expected initial result: fail because CLI does not exist. + +### Implementation + +CLI shape: + +```bash +python3 -m skill_scripts.cli_report_harness \ + --prompt "請產出2026第一季費用分析,依部門與會計科目彙總" \ + --input-file /path/to/需求.xlsx \ + --run-dir wferp-report-runs/expense-analysis-demo \ + --mode rule \ + --checkpoint excel +``` + +Execution step: + +```bash +python3 -m skill_scripts.cli_report_harness \ + --run-dir wferp-report-runs/expense-analysis-demo \ + --confirm-sql \ + --validate-execution +``` + +Report generation step: + +```bash +python3 -m skill_scripts.cli_report_harness \ + --run-dir wferp-report-runs/expense-analysis-demo \ + --report-type 管理摘要 \ + --report-design financial-control \ + --include-chart \ + --include-table \ + --include-analysis \ + --include-recommendations +``` + +### Verification + +```bash +pytest tests/skill_scripts/test_cli_report_harness.py -v +python3 -m skill_scripts.cli_report_harness --help +``` + +## Task 13: Add Validator Prompts And Evidence Packets + +### Files + +- Add: `skill_scripts/validator_contracts.py` +- Add: `tests/skill_scripts/test_validator_contracts.py` +- Add local skill references: + - `/home/timmypai/.codex/skills/wferp-report/references/validators.md` + +### Tests First + +Test cases: + +- `test_validator_contracts_include_required_roles` +- `test_validator_contract_requires_status_evidence_and_findings` +- `test_validator_contract_rejects_missing_quantitative_checks_for_data_validator` +- `test_report_final_review_requires_all_validators_pass_or_explicit_user_acceptance` + +Expected initial command: + +```bash +pytest tests/skill_scripts/test_validator_contracts.py -v +``` + +Expected initial result: fail because contracts do not exist. + +### Implementation + +Evidence packet JSON shape: + +```json +{ + "validator": "sql_safety", + "status": "pass", + "checked_at": "2026-06-18T00:00:00+08:00", + "inputs": ["sql/expense_analysis.sql"], + "checks": [ + { + "name": "readonly_select_only", + "status": "pass", + "evidence": "SQL starts with SELECT and contains zero blocked keywords." + } + ], + "findings": [], + "residual_risks": [] +} +``` + +Required validators: + +- `source_intake` +- `excel_formula` +- `sql_safety` +- `schema_relationship` +- `data_preview` +- `report_content` +- `visual_technical` + +### Verification + +```bash +pytest tests/skill_scripts/test_validator_contracts.py -v +``` + +## Task 14: Full Regression And Documentation Pass + +### Files + +- Modify: `README.md` if present. +- Modify: `INSTALLATION.md` +- Modify: `OPERATIONS.md` +- Modify: `docs/superpowers/specs/2026-06-18-wferp-report-skill-and-react-renderer-design.md` only if implementation discovers a necessary correction. + +### Verification Commands + +Run focused backend tests: + +```bash +pytest tests/source/test_schema_db_config.py -v +pytest tests/scripts/test_quarantine_legacy_static_docs.py -v +pytest tests/scripts/test_validate_local_wferp_report_skill.py -v +pytest tests/skill_scripts/test_sql_generator.py -v +pytest tests/skill_scripts/test_postgres_readonly_adapter.py -v +pytest tests/skill_scripts/test_excel_intake.py -v +pytest tests/skill_scripts/test_report_catalog.py -v +pytest tests/skill_scripts/test_report_harness_state.py tests/skill_scripts/test_report_harness.py -v +pytest tests/skill_scripts/test_cli_report_harness.py -v +pytest tests/skill_scripts/test_validator_contracts.py -v +``` + +Run required real E2E: + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +``` + +Run existing suite: + +```bash +pytest tests/skill_scripts/ -v +``` + +Run renderer checks: + +```bash +cd report_renderer +npm test +npm run build +``` + +Final repository checks: + +```bash +git status --short +python3 - <<'PY' +from pathlib import Path +markers = ["TO" + "DO", "TB" + "D", "place" + "holder", "待" + "定"] +roots = [ + Path("docs/superpowers/plans"), + Path("docs/superpowers/specs"), + Path("skill_scripts"), + Path("tests"), + Path("report_designs"), + Path("report_renderer"), +] +for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + for marker in markers: + if marker in text: + print(f"{path}: contains unfinished marker {marker}") +PY +rg "SERVER_IP|USERNAME|PASSWORD|DATABASE =" _Source/1_mssql_to_json.py || true +``` + +Expected: + +- All focused tests pass. +- Expense E2E passes with real Docker PostgreSQL execution. +- Existing `tests/skill_scripts/` pass. +- Renderer test/build pass. +- No accidental old credential constants remain. +- Git status only contains intentional changes. + +## Commit Plan + +Recommended commits: + +1. `refactor: externalize schema rebuild database config` +2. `chore: quarantine legacy static docs` +3. `feat: add expense analysis SQL generation` +4. `test: add postgres expense analysis e2e` +5. `feat: parse excel report requirements` +6. `feat: add report harness and catalog` +7. `feat: add react report renderer` +8. `feat: install local wferp report skill` +9. `docs: document wferp report workflow` + +## Risk Controls + +- Keep SQL generation deterministic for the E2E path; LLM-based generation can remain an optional future path, but this plan's acceptance cannot depend on an LLM. +- Treat PostgreSQL adapter as test-only. Do not claim PostgreSQL is production target for WFERP. +- Keep report renderer pure: it consumes payload JSON and does not read DB env vars or execute SQL. +- Any write to `/home/timmypai/.codex/skills/wferp-report/` must request sandbox escalation during implementation. +- If dependency install is needed (`openpyxl`, `psycopg`, frontend packages), request network escalation and record exact installed package versions in docs. + +## Goal Prompt For Implementation Worker + +Paste the following prompt into a fresh Codex worker when ready to execute this implementation plan: + +```text +你是 Codex implementation worker。請在 repo `/home/timmypai/.codex/worktrees/5f5b/wferp` 依照 `/home/timmypai/.codex/worktrees/5f5b/wferp/docs/superpowers/plans/2026-06-18-wferp-report-skill-and-react-renderer.md` 完整執行。 + +必須遵守: +- 回覆使用者使用繁體中文。 +- 先使用 `superpowers:executing-plans`,逐項執行 plan。 +- 先寫 failing test,再實作,再跑對應驗證。 +- 不可刪除或回復使用者既有變更。 +- local skill 必須建立在 `/home/timmypai/.codex/skills/wferp-report/`,不可放入 worktree;需要寫入該路徑時依 sandbox 規則請求 escalation。 +- 保留 `_Source/1_mssql_to_json.py` 與 `_Source/2_FieldNameConvert2utf8.py` 的 schema rebuild 功能,但 DB 連線設定必須改由 `WFERP_SCHEMA_DB_*` 環境變數提供,不可硬編碼 credentials。 +- 將 root `index.html`、`df_style.css`、`HTML/` 與 `_Source/3_CreateIndexHtml.py`、`_Source/4_CreateTableStructureHtml.py`、`_Source/5_CreateTableStructureSQL.py` 移至隔離區,保留 schema JSON 與 SQL tooling。 +- 新增費用分析 E2E:用 Docker PostgreSQL 當本地 MS-SQL substitute,建立真實 WFERP fixture schema/data,產生真實 SQL,經 test-only readonly adapter 轉譯後執行真實查詢。此 E2E 不可使用 fake、smoke、mock。 +- E2E 量化驗收必須包含:6 grouped rows、untaxed total 130000.00、tax total 6500.00、total amount 136500.00、D002/6201 top row 37800.00、排除 2025/未確認/作廢控制資料、percentage sum 約等於 100。 +- 新增 Excel intake,可解析需求欄位、使用者公式欄位、公式 lineage、管理報表輸出欄位,並產生中文確認 payload。 +- 新增 report catalog/design repository,至少包含:明細查詢表、彙總統計表、趨勢分析表、比較分析表、異常稽核表、管理摘要、完整分析報告。 +- 新增 React renderer,最終 checkpoint/report HTML 必須由 React 渲染,不使用舊 `index.html` 或 `HTML/*.html`。 +- 新增 validator contracts,支援 source intake、excel formula、sql safety、schema relationship、data preview、report content、visual technical validator evidence packet。 + +驗證至少執行: +- `pytest tests/skill_scripts/ -v` +- `bash scripts/run_expense_analysis_postgres_e2e.sh` +- React renderer 的 `npm test` 與 `npm run build`,若 frontend dependencies 需要安裝,先請求 escalation。 +- schema DB config、quarantine、local skill validator、Excel intake、report harness、report catalog、validator contracts 的 focused tests。 + +完成後請輸出: +1. 已完成的檔案與功能摘要。 +2. 實際執行過的驗證命令與結果。 +3. 若有未完成或被 sandbox/network 阻擋的項目,列出具體 blocker 與下一步。 +4. 建議下一步是否 commit、merge 或請使用者試跑 skill。 +``` + +## Execution Notes + +- This plan intentionally separates design/spec from implementation. Do not start by rewriting the design spec unless implementation evidence contradicts it. +- Prefer small helpers over broad rewrites in existing `skill_scripts/`. +- Keep Chinese/Vietnamese metadata UTF-8 intact. +- Do not run destructive cleanup commands. Use the quarantine script for planned moves. diff --git a/docs/superpowers/plans/2026-06-18-wferp-single-html-report-studio.md b/docs/superpowers/plans/2026-06-18-wferp-single-html-report-studio.md new file mode 100644 index 0000000..c2df410 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-wferp-single-html-report-studio.md @@ -0,0 +1,2016 @@ +# WFERP Single HTML Report Studio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a catalog-guided dynamic report design pipeline that exports verified WFERP reports as fully self-contained single HTML files plus evidence packets, with style replay support. + +**Architecture:** The implementation adds a report package layer as the immutable data source, a dynamic design brief and visual checkpoint layer for user-confirmed layout decisions, a single HTML exporter that inlines runtime/assets/compressed package data, and validators/E2E that prove data correctness, offline safety, visual quality, and style replay. Existing harness checkpoints remain the gatekeeper; browser-side code never connects to DB or executes SQL. + +**Tech Stack:** Python 3 standard library, pytest, existing `skill_scripts/*`, React/Vite renderer, Vitest, Playwright or local browser automation for HTML validation, Docker PostgreSQL E2E fixture, SQLite E2E fixture. + +--- + +## File Structure + +### New Python Modules + +- `skill_scripts/report_package.py` + - Builds and validates `report-package.json`. + - Provides deterministic hashing and smart-tiered embedded data policy. +- `skill_scripts/dynamic_design_brief.py` + - Builds `report-design-brief.json` from prompt, catalog guardrail, data profile, and user options. + - Validates confirmed design brief schema. +- `skill_scripts/visual_checkpoint.py` + - Builds semi-real visual checkpoint payload and HTML fragment from a design brief. +- `skill_scripts/single_html_exporter.py` + - Writes `delivery/report.html`, `delivery/evidence/*`, `delivery/delivery-manifest.json`. + - Bundles inline CSS/JS/package data without network dependencies. +- `skill_scripts/html_self_validator.py` + - Validates exported single HTML by static checks and browser checks. +- `skill_scripts/style_replay.py` + - Extracts `report-style-capsule.json`, applies it to a new run, and detects incompatible chart/data shapes. + +### New Tests + +- `tests/skill_scripts/test_report_package.py` +- `tests/skill_scripts/test_dynamic_design_brief.py` +- `tests/skill_scripts/test_visual_checkpoint.py` +- `tests/skill_scripts/test_single_html_exporter.py` +- `tests/skill_scripts/test_html_self_validator.py` +- `tests/skill_scripts/test_style_replay.py` +- `tests/skill_scripts/test_single_html_expense_e2e.py` +- `tests/skill_scripts/test_style_replay_e2e.py` + +### Modified Files + +- `skill_scripts/report_harness.py` + - Add checkpoint methods for design brief, visual checkpoint, package, and delivery export state. +- `skill_scripts/report_harness_state.py` + - Add checkpoint definitions for dynamic design and delivery export. +- `skill_scripts/cli_report_harness.py` + - Add subcommands for design brief, visual checkpoint, package, export, validate HTML, replay style. +- `skill_scripts/validator_contracts.py` + - Add validator roles and result support for single HTML, visual interaction, and style replay. +- `report_renderer/src/*` + - Export reusable runtime entrypoints for single HTML bundle. +- `report_renderer/tests/renderer.spec.ts` + - Add offline runtime and interaction contract tests. +- `scripts/run_single_html_expense_e2e.sh` +- `scripts/run_style_replay_e2e.sh` +- `/home/timmypai/.codex/skills/wferp-report/SKILL.md` +- `/home/timmypai/.codex/skills/wferp-report/README.md` +- `/home/timmypai/.codex/skills/wferp-report/references/html-output.md` +- `/home/timmypai/.codex/skills/wferp-report/references/harness.md` +- `/home/timmypai/.codex/skills/wferp-report/references/component-policy.md` +- `/home/timmypai/.codex/skills/wferp-report/references/validators.md` + +### New Local Skill Reference Files + +- `/home/timmypai/.codex/skills/wferp-report/references/single-html-export.md` +- `/home/timmypai/.codex/skills/wferp-report/references/dynamic-design-brief.md` +- `/home/timmypai/.codex/skills/wferp-report/references/style-replay.md` + +--- + +## Task 1: Report Package Contract + +**Files:** +- Create: `skill_scripts/report_package.py` +- Create: `tests/skill_scripts/test_report_package.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `skill_scripts/report_harness_state.py` + +- [ ] **Step 1: Write failing tests for report package build and hash stability** + +Add to `tests/skill_scripts/test_report_package.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +from skill_scripts.report_harness import ReportHarness +from skill_scripts.report_package import build_report_package, validate_report_package + + +def _accepted_run(tmp_path: Path) -> ReportHarness: + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用分析") + harness.write_sql_review( + "SELECT department_code, amount, budget_amount FROM expenses", + {"status": "pass", "readonly": True, "blocked_keywords": []}, + ) + harness.confirm("sql_review", "同意查詢") + harness.write_data_preview( + { + "columns": ["department_code", "amount", "budget_amount"], + "rows": [ + {"department_code": "ADM", "amount": 35000, "budget_amount": 30000}, + {"department_code": "RND", "amount": 30000, "budget_amount": 25000}, + ], + "row_count": 2, + "aggregates": {"total_amount": 65000, "total_budget": 55000, "variance_amount": 10000}, + "excluded_rows": {"non_2026": 1, "non_expense_account": 1}, + } + ) + harness.confirm("data_preview", "確認資料") + harness.write_report_selection( + { + "selected_report_type": "管理摘要", + "selected_report_design": "financial-control", + "selected_options": {"include_chart": True, "include_table": True}, + } + ) + harness.confirm("report_selection", "產生報告") + harness.write_report_draft({"sections": ["主管摘要", "費用差異"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": [ + { + "role": role, + "status": "pass", + "evidence": [{"command": "pytest"}], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + for role in [ + "source_requirement_reviewer", + "excel_formula_reviewer", + "sql_safety_reviewer", + "schema_relationship_reviewer", + "data_preview_reviewer", + "report_content_reviewer", + "visual_taste_reviewer", + "data_visualization_reviewer", + "react_technical_reviewer", + ] + ] + } + ) + harness.confirm("final_review", "完成") + return harness + + +def test_build_report_package_uses_confirmed_run_state(tmp_path: Path): + harness = _accepted_run(tmp_path) + + package = build_report_package(harness.run_dir) + + assert package["package_id"] == "run-001" + assert package["catalog_guardrail"] == "financial-control" + assert package["sql"]["text"].startswith("SELECT") + assert package["data_profile"]["row_count"] == 2 + assert package["datasets"]["embedded_rows"][0]["department_code"] == "ADM" + assert package["aggregates"]["total_amount"] == 65000 + assert package["evidence_index"] + assert package["hashes"]["package_sha256"] + assert validate_report_package(package)["valid"] is True + + +def test_report_package_hash_is_deterministic(tmp_path: Path): + harness = _accepted_run(tmp_path) + + first = build_report_package(harness.run_dir) + second = build_report_package(harness.run_dir) + + assert first["hashes"]["package_sha256"] == second["hashes"]["package_sha256"] + + +def test_report_package_rejects_credentials_and_unconfirmed_delivery(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-002", prompt="查詢費用") + harness.update_state(db_connection_string="server=prod;password=secret") + + package = build_report_package(harness.run_dir) + result = validate_report_package(package) + + assert result["valid"] is False + assert "credentials" in result["errors"] + assert "delivery_gate" in result["errors"] +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pytest tests/skill_scripts/test_report_package.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'skill_scripts.report_package'`. + +- [ ] **Step 3: Implement report package builder** + +Create `skill_scripts/report_package.py` with these public functions: + +```python +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from skill_scripts.report_harness import ReportHarness + +FORBIDDEN_SECRET_KEYS = {"password", "pwd", "credential", "connection_string", "db_connection_string"} + + +def _canonical_json(data: dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _contains_secret_key(data: Any) -> bool: + if isinstance(data, dict): + for key, value in data.items(): + lowered = str(key).lower() + if any(secret in lowered for secret in FORBIDDEN_SECRET_KEYS): + return True + if _contains_secret_key(value): + return True + if isinstance(data, list): + return any(_contains_secret_key(item) for item in data) + return False + + +def _data_profile(result: dict[str, Any]) -> dict[str, Any]: + rows = result.get("rows", []) + return { + "row_count": int(result.get("row_count", len(rows))), + "columns": list(result.get("columns", list(rows[0].keys()) if rows else [])), + "embedded_mode": "full_rows" if len(rows) <= 5000 else "summary_plus_preview", + "embedded_rows": min(len(rows), 5000), + "full_rows_in_evidence_packet": len(rows) > 5000, + } + + +def build_report_package(run_dir: str | Path) -> dict[str, Any]: + harness = ReportHarness(run_dir) + state = harness.state() + execution_result = copy.deepcopy(state.get("execution_result_summary") or {}) + sql_text = str(state.get("sql_candidate") or "") + package: dict[str, Any] = { + "schema_version": "wferp.report-package.v1", + "package_id": state["run_id"], + "prompt": state.get("prompt", ""), + "catalog_guardrail": state.get("report_design") or "financial-control", + "report_type": state.get("report_type"), + "report_options": copy.deepcopy(state.get("report_options") or {}), + "sql": { + "text": sql_text, + "validation": copy.deepcopy(state.get("sql_validation") or {}), + }, + "data_profile": _data_profile(execution_result), + "datasets": { + "embedded_rows": copy.deepcopy(execution_result.get("rows", []))[:5000], + "charts": {}, + "tables": {}, + "drilldowns": {}, + }, + "columns": copy.deepcopy(execution_result.get("columns", [])), + "aggregates": copy.deepcopy(execution_result.get("aggregates", {})), + "excluded_rows": copy.deepcopy(execution_result.get("excluded_rows", {})), + "validator_summary": copy.deepcopy(state.get("validator_results", [])), + "accepted_residual_risks": harness.can_deliver().get("accepted_residual_risks", []), + "delivery_gate": harness.can_deliver(), + "evidence_index": [ + {"id": "sql", "path": "evidence/query.sql"}, + {"id": "execution_result", "path": "evidence/execution-result.json"}, + {"id": "validators", "path": "evidence/validator-results.json"}, + ], + "hashes": {}, + } + package_without_hash = copy.deepcopy(package) + package_without_hash["hashes"] = {} + package["hashes"]["package_sha256"] = _sha256_text(_canonical_json(package_without_hash)) + package["hashes"]["sql_sha256"] = _sha256_text(sql_text) + return package + + +def validate_report_package(package: dict[str, Any]) -> dict[str, Any]: + errors: list[str] = [] + if _contains_secret_key(package): + errors.append("credentials") + if not package.get("delivery_gate", {}).get("allowed"): + errors.append("delivery_gate") + if not str(package.get("sql", {}).get("text", "")).lstrip().upper().startswith("SELECT"): + errors.append("sql_readonly") + if not package.get("data_profile", {}).get("columns"): + errors.append("columns") + if not package.get("hashes", {}).get("package_sha256"): + errors.append("package_hash") + return {"valid": not errors, "errors": errors} +``` + +Modify `ReportHarness` only if the existing confirmed action names do not match the tests. Keep all existing checkpoint behavior. + +- [ ] **Step 4: Run package tests** + +Run: + +```bash +pytest tests/skill_scripts/test_report_package.py -v +``` + +Expected: PASS, `3 passed`. + +- [ ] **Step 5: Commit** + +```bash +git add skill_scripts/report_package.py tests/skill_scripts/test_report_package.py skill_scripts/report_harness.py skill_scripts/report_harness_state.py +git commit -m "feat: build verified report package" +``` + +--- + +## Task 2: Dynamic Design Brief And Design Checkpoint + +**Files:** +- Create: `skill_scripts/dynamic_design_brief.py` +- Create: `tests/skill_scripts/test_dynamic_design_brief.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `skill_scripts/report_harness_state.py` +- Modify: `skill_scripts/cli_report_harness.py` +- Modify: `tests/skill_scripts/test_report_harness.py` +- Modify: `tests/skill_scripts/test_report_package.py` + +- [ ] **Step 1: Write failing tests for brief generation and confirmation gate** + +Create `tests/skill_scripts/test_dynamic_design_brief.py`: + +```python +from __future__ import annotations + +from pathlib import Path +import pytest + +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief +from skill_scripts.report_harness import ReportHarness, ReportHarnessError + + +def _package() -> dict: + return { + "package_id": "run-001", + "prompt": "請產出費用分析,依部門比較預算與實際", + "catalog_guardrail": "financial-control", + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio", + ], + }, + "aggregates": {"total_amount": 120000, "total_budget": 100000, "variance_amount": 20000}, + } + + +def test_build_design_brief_uses_catalog_guardrail_and_data_shape(): + brief = build_design_brief(_package()) + + assert brief["schema_version"] == "wferp.design-brief.v1" + assert brief["catalog_guardrail"] == "financial-control" + assert brief["layout_recipe"]["mode"] == "kpi-first-dashboard" + assert [chart["type"] for chart in brief["chart_recipe"]] == ["combo", "stacked-bar", "bar"] + assert brief["table_recipe"][0]["features"] == ["filter", "sort", "drilldown", "column_visibility"] + assert brief["embedded_data_policy"]["mode"] == "smart-tiered" + assert validate_design_brief(brief)["valid"] is True + + +def test_design_brief_validation_rejects_missing_chart_purpose(): + brief = build_design_brief(_package()) + del brief["chart_recipe"][0]["purpose"] + + result = validate_design_brief(brief) + + assert result["valid"] is False + assert "chart_recipe[0].purpose" in result["errors"] + + +def test_report_draft_requires_confirmed_design_brief(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="費用分析") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + + with pytest.raises(ReportHarnessError, match="Design brief must be confirmed"): + harness.write_report_draft({"sections": ["主管摘要"]}) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pytest tests/skill_scripts/test_dynamic_design_brief.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'skill_scripts.dynamic_design_brief'`. + +- [ ] **Step 3: Implement design brief builder** + +Create `skill_scripts/dynamic_design_brief.py`: + +```python +from __future__ import annotations + +from typing import Any + + +REQUIRED_TOP_LEVEL = [ + "schema_version", + "report_intent", + "catalog_guardrail", + "target_audience", + "layout_recipe", + "chart_recipe", + "table_recipe", + "interaction_recipe", + "visual_direction", + "embedded_data_policy", +] + + +def build_design_brief(package: dict[str, Any], *, user_overrides: dict[str, Any] | None = None) -> dict[str, Any]: + catalog = str(package.get("catalog_guardrail") or "financial-control") + columns = set(package.get("data_profile", {}).get("columns", [])) + prompt = str(package.get("prompt") or "") + charts = [ + {"id": "budget_vs_actual", "type": "combo", "purpose": "比較實際與預算"}, + {"id": "department_mix", "type": "stacked-bar", "purpose": "呈現部門費用組成"}, + {"id": "variance_exceptions", "type": "bar", "purpose": "凸顯超支與差異項目"}, + ] + if "period" in columns or "month" in columns or "date" in columns: + charts.insert(0, {"id": "period_trend", "type": "line", "purpose": "呈現期間趨勢"}) + brief: dict[str, Any] = { + "schema_version": "wferp.design-brief.v1", + "report_intent": "費用分析" if "費用" in prompt else "管理報表", + "catalog_guardrail": catalog, + "target_audience": "部門主管與財務主管", + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": ["主管摘要", "費用差異", "部門排行", "明細 drilldown", "建議與風險"], + }, + "chart_recipe": charts, + "table_recipe": [ + { + "id": "detail_drilldown", + "type": "interactive-detail", + "features": ["filter", "sort", "drilldown", "column_visibility"], + } + ], + "interaction_recipe": {"cross_filter": True, "drilldown": True, "evidence_drawer": "collapsed"}, + "visual_direction": { + "density": "dense", + "tone": "管理控制", + "emphasis": ["variance", "exception", "traceability"], + }, + "embedded_data_policy": {"mode": "smart-tiered", "preview_rows": 200, "full_rows_threshold": 5000}, + } + if user_overrides: + for key, value in user_overrides.items(): + brief[key] = value + return brief + + +def validate_design_brief(brief: dict[str, Any]) -> dict[str, Any]: + errors: list[str] = [] + for key in REQUIRED_TOP_LEVEL: + if not brief.get(key): + errors.append(key) + for index, chart in enumerate(brief.get("chart_recipe", [])): + for key in ("id", "type", "purpose"): + if not chart.get(key): + errors.append(f"chart_recipe[{index}].{key}") + for index, table in enumerate(brief.get("table_recipe", [])): + if not table.get("features"): + errors.append(f"table_recipe[{index}].features") + return {"valid": not errors, "errors": errors} +``` + +- [ ] **Step 4: Add harness checkpoint methods** + +Modify `skill_scripts/report_harness_state.py` checkpoint definitions to include: + +```python +"design_brief": { + "index": 4.1, + "file": "04a_design_brief.json", + "title": "動態設計確認", + "actions": ["確認設計", "調整設計"], +}, +``` + +Modify `ReportHarness`: + +```python +def write_design_brief(self, payload: dict[str, Any]) -> dict[str, Any]: + self.clear_downstream(["visual_design", "report_draft", "final_review"], validator_results=[]) + self.invalidate_confirmations("design_brief") + self.update_state(report_design_brief=payload) + return record_checkpoint(self.run_dir, "design_brief", payload) +``` + +Modify `write_report_draft` gate: + +```python +if self.state().get("user_confirmations", {}).get("design_brief") != "確認設計": + raise ReportHarnessError("Design brief must be confirmed before writing draft") +``` + +Update `tests/skill_scripts/test_report_harness.py` by adding this helper near `_all_validator_results`: + +```python +def _confirm_design_brief(harness: ReportHarness) -> None: + harness.write_design_brief( + { + "schema_version": "wferp.design-brief.v1", + "report_intent": "費用分析", + "catalog_guardrail": "financial-control", + "layout_recipe": {"mode": "kpi-first-dashboard", "sections": ["主管摘要"]}, + "chart_recipe": [{"id": "amount", "type": "bar", "purpose": "費用比較"}], + "table_recipe": [{"id": "detail", "type": "interactive-detail", "features": ["filter"]}], + "interaction_recipe": {"cross_filter": True}, + "visual_direction": {"density": "dense", "tone": "管理控制"}, + "embedded_data_policy": {"mode": "smart-tiered"}, + } + ) + harness.confirm("design_brief", "確認設計") +``` + +In every existing `tests/skill_scripts/test_report_harness.py` path that calls `harness.write_report_draft(...)` after confirming `report_selection`, insert `_confirm_design_brief(harness)` before `write_report_draft(...)`. + +Update `tests/skill_scripts/test_report_package.py` helper `_accepted_run` in the same way: after `harness.confirm("report_selection", "產生報告")`, call `harness.write_design_brief(...)` with the payload above and then `harness.confirm("design_brief", "確認設計")`. + +Modify `write_report_selection` so a changed catalog or options invalidates dynamic design and all downstream artifacts: + +```python +self.clear_downstream( + ["design_brief", "visual_design", "report_draft", "final_review"], + report_design_brief=None, + visual_design_checkpoint=None, + validator_results=[], +) +``` + +- [ ] **Step 5: Add CLI subcommand** + +Modify `skill_scripts/cli_report_harness.py`: + +```python +def _write_design_brief(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the dynamic report design brief checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--package", required=True) + parser.add_argument("--overrides", default="") + args = parser.parse_args(argv) + try: + harness = _open_harness(args.run_dir) + package = _load_json_arg(args.package) + overrides = _load_json_arg_or_empty(args.overrides) + brief = build_design_brief(package, user_overrides=overrides) + result = validate_design_brief(brief) + if not result["valid"]: + return _json_error("design_brief_invalid", ", ".join(result["errors"])) + checkpoint = harness.write_design_brief(brief) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("design_brief_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 +``` + +Add import: + +```python +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief +``` + +Add command mapping: + +```python +"write-design-brief": _write_design_brief, +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +pytest tests/skill_scripts/test_dynamic_design_brief.py tests/skill_scripts/test_report_harness.py tests/skill_scripts/test_report_package.py tests/skill_scripts/test_cli_report_harness.py -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add skill_scripts/dynamic_design_brief.py tests/skill_scripts/test_dynamic_design_brief.py skill_scripts/report_harness.py skill_scripts/report_harness_state.py skill_scripts/cli_report_harness.py tests/skill_scripts/test_report_harness.py tests/skill_scripts/test_report_package.py tests/skill_scripts/test_cli_report_harness.py +git commit -m "feat: add dynamic report design brief" +``` + +--- + +## Task 3: Semi-real HTML Visual Checkpoint + +**Files:** +- Create: `skill_scripts/visual_checkpoint.py` +- Create: `tests/skill_scripts/test_visual_checkpoint.py` +- Modify: `skill_scripts/report_harness.py` +- Modify: `skill_scripts/report_harness_state.py` +- Modify: `skill_scripts/cli_report_harness.py` +- Modify: `tests/skill_scripts/test_report_harness.py` +- Modify: `tests/skill_scripts/test_report_package.py` +- Modify: `tests/skill_scripts/test_checkpoint_companion.py` + +- [ ] **Step 1: Write failing tests for visual checkpoint output** + +Create `tests/skill_scripts/test_visual_checkpoint.py`: + +```python +from __future__ import annotations + +from skill_scripts.visual_checkpoint import build_visual_checkpoint_payload, render_visual_checkpoint_html + + +def _brief() -> dict: + return { + "schema_version": "wferp.design-brief.v1", + "report_intent": "費用分析", + "catalog_guardrail": "financial-control", + "layout_recipe": {"mode": "kpi-first-dashboard", "sections": ["主管摘要", "費用差異"]}, + "chart_recipe": [{"id": "budget_vs_actual", "type": "combo", "purpose": "比較實際與預算"}], + "table_recipe": [{"id": "detail", "type": "interactive-detail", "features": ["filter", "sort"]}], + "interaction_recipe": {"cross_filter": True, "drilldown": True, "evidence_drawer": "collapsed"}, + "visual_direction": {"density": "dense", "tone": "管理控制", "emphasis": ["variance"]}, + "embedded_data_policy": {"mode": "smart-tiered", "preview_rows": 200, "full_rows_threshold": 5000}, + } + + +def _package() -> dict: + return { + "package_id": "run-001", + "aggregates": {"total_amount": 120000, "total_budget": 100000, "variance_amount": 20000}, + "data_profile": {"row_count": 6, "columns": ["department_name", "amount", "budget_amount"]}, + } + + +def test_visual_checkpoint_payload_uses_real_labels_and_aggregates(): + payload = build_visual_checkpoint_payload(_brief(), _package()) + + assert payload["title"] == "費用分析視覺設計確認" + assert payload["catalog_guardrail"] == "financial-control" + assert payload["kpis"][0] == {"label": "total_amount", "value": 120000} + assert payload["charts"][0]["type"] == "combo" + assert payload["layout"]["sections"] == ["主管摘要", "費用差異"] + + +def test_visual_checkpoint_html_is_semireal_and_has_no_runtime_db_access(): + html = render_visual_checkpoint_html(build_visual_checkpoint_payload(_brief(), _package())) + + assert "費用分析視覺設計確認" in html + assert "比較實際與預算" in html + assert "total_amount" in html + assert "fetch(" not in html + assert "new WebSocket" not in html + assert "SQL" not in html.upper() +``` + +Add this harness gate test to the same file: + +```python +import pytest + +from skill_scripts.report_harness import ReportHarness, ReportHarnessError + + +def test_report_draft_requires_confirmed_visual_design(tmp_path): + harness = ReportHarness.create(tmp_path, run_id="run-visual", prompt="費用分析") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + harness.write_design_brief(_brief()) + harness.confirm("design_brief", "確認設計") + + with pytest.raises(ReportHarnessError, match="Visual design must be confirmed"): + harness.write_report_draft({"sections": ["主管摘要"]}) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pytest tests/skill_scripts/test_visual_checkpoint.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'skill_scripts.visual_checkpoint'`. + +- [ ] **Step 3: Implement visual checkpoint builder** + +Create `skill_scripts/visual_checkpoint.py`: + +```python +from __future__ import annotations + +import html +from typing import Any + + +def build_visual_checkpoint_payload(brief: dict[str, Any], package: dict[str, Any]) -> dict[str, Any]: + aggregates = package.get("aggregates", {}) + return { + "title": f"{brief.get('report_intent', '報表')}視覺設計確認", + "catalog_guardrail": brief["catalog_guardrail"], + "layout": brief["layout_recipe"], + "kpis": [{"label": key, "value": value} for key, value in aggregates.items()], + "charts": brief["chart_recipe"], + "tables": brief["table_recipe"], + "interactions": brief["interaction_recipe"], + "visual_direction": brief["visual_direction"], + "data_profile": package.get("data_profile", {}), + } + + +def render_visual_checkpoint_html(payload: dict[str, Any]) -> str: + cards = "\n".join( + f"
{html.escape(item['label'])}: {html.escape(str(item['value']))}
" + for item in payload.get("kpis", []) + ) + charts = "\n".join( + f"
{html.escape(chart['type'])}: {html.escape(chart['purpose'])}
" + for chart in payload.get("charts", []) + ) + sections = "\n".join( + f"
  • {html.escape(str(section))}
  • " for section in payload.get("layout", {}).get("sections", []) + ) + return f""" +
    +

    {html.escape(payload["title"])}

    +

    Catalog guardrail: {html.escape(payload["catalog_guardrail"])}

    +
    +
    Semi-real preview
    +
    +
    {cards}
    +
    {charts}
    +
    Interactive table / drilldown / evidence drawer
    +
      {sections}
    +
    +
    +
    +""".strip() +``` + +- [ ] **Step 4: Add harness and CLI checkpoint** + +Add checkpoint definition: + +```python +"visual_design": { + "index": 4.2, + "file": "04b_visual_design.json", + "title": "視覺設計確認", + "actions": ["確認視覺設計", "調整視覺設計"], +}, +``` + +Add `ReportHarness.write_visual_design(payload)`: + +```python +def write_visual_design(self, payload: dict[str, Any]) -> dict[str, Any]: + if self.state().get("user_confirmations", {}).get("design_brief") != "確認設計": + raise ReportHarnessError("Design brief must be confirmed before visual checkpoint") + self.clear_downstream(["report_draft", "final_review"], validator_results=[]) + self.invalidate_confirmations("visual_design") + self.update_state(visual_design_checkpoint=payload) + return record_checkpoint(self.run_dir, "visual_design", payload) +``` + +Tighten `write_report_draft` after the visual checkpoint is introduced: + +```python +if self.state().get("user_confirmations", {}).get("visual_design") != "確認視覺設計": + raise ReportHarnessError("Visual design must be confirmed before writing draft") +``` + +Update `tests/skill_scripts/test_report_harness.py` by extending the Task 2 helper: + +```python +def _confirm_visual_design(harness: ReportHarness) -> None: + harness.write_visual_design( + { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": {"mode": "kpi-first-dashboard", "sections": ["主管摘要"]}, + "kpis": [{"label": "total_amount", "value": 120000}], + "charts": [{"id": "amount", "type": "bar", "purpose": "費用比較"}], + "tables": [{"id": "detail", "type": "interactive-detail", "features": ["filter"]}], + "interactions": {"cross_filter": True}, + "visual_direction": {"density": "dense", "tone": "管理控制"}, + "data_profile": {"row_count": 2}, + } + ) + harness.confirm("visual_design", "確認視覺設計") +``` + +In `tests/skill_scripts/test_report_harness.py`, `tests/skill_scripts/test_report_package.py`, and `tests/skill_scripts/test_checkpoint_companion.py`, insert `_confirm_visual_design(harness)` after `_confirm_design_brief(harness)` and before `write_report_draft(...)`. + +Add CLI command `write-visual-checkpoint`: + +```python +def _write_visual_checkpoint(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write semi-real visual design checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--brief", required=True) + parser.add_argument("--package", required=True) + args = parser.parse_args(argv) + try: + harness = _open_harness(args.run_dir) + payload = build_visual_checkpoint_payload(_load_json_arg(args.brief), _load_json_arg(args.package)) + html_text = render_visual_checkpoint_html(payload) + _write_run_json(harness.run_dir, "visual/visual-checkpoint.html", {"html": html_text}) + checkpoint = harness.write_visual_design(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("visual_checkpoint_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 +``` + +Add import: + +```python +from skill_scripts.visual_checkpoint import build_visual_checkpoint_payload, render_visual_checkpoint_html +``` + +Add command mapping: + +```python +"write-visual-checkpoint": _write_visual_checkpoint, +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +pytest tests/skill_scripts/test_visual_checkpoint.py tests/skill_scripts/test_report_harness.py tests/skill_scripts/test_report_package.py tests/skill_scripts/test_checkpoint_companion.py tests/skill_scripts/test_cli_report_harness.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skill_scripts/visual_checkpoint.py tests/skill_scripts/test_visual_checkpoint.py skill_scripts/report_harness.py skill_scripts/report_harness_state.py skill_scripts/cli_report_harness.py tests/skill_scripts/test_report_harness.py tests/skill_scripts/test_report_package.py tests/skill_scripts/test_checkpoint_companion.py tests/skill_scripts/test_cli_report_harness.py +git commit -m "feat: add visual design checkpoint" +``` + +--- + +## Task 4: Single HTML Exporter And Evidence Packet + +**Files:** +- Create: `skill_scripts/single_html_exporter.py` +- Create: `tests/skill_scripts/test_single_html_exporter.py` +- Modify: `skill_scripts/cli_report_harness.py` +- Modify: `report_renderer/README.md` + +- [ ] **Step 1: Write failing tests for self-contained HTML and evidence packet** + +Create `tests/skill_scripts/test_single_html_exporter.py`: + +```python +from __future__ import annotations + +import json +from pathlib import Path + +from skill_scripts.single_html_exporter import export_single_html_report + + +def _package() -> dict: + return { + "schema_version": "wferp.report-package.v1", + "package_id": "run-001", + "prompt": "費用分析", + "catalog_guardrail": "financial-control", + "sql": {"text": "SELECT department_code, amount FROM expenses", "validation": {"readonly": True}}, + "data_profile": {"row_count": 1, "columns": ["department_code", "amount"], "embedded_mode": "full_rows"}, + "datasets": {"embedded_rows": [{"department_code": "ADM", "amount": 35000}], "charts": {}, "tables": {}, "drilldowns": {}}, + "aggregates": {"total_amount": 35000}, + "validator_summary": [], + "evidence_index": [{"id": "sql", "path": "evidence/query.sql"}], + "hashes": {"package_sha256": "test-package-sha", "sql_sha256": "test-sql-sha"}, + } + + +def _brief() -> dict: + return { + "schema_version": "wferp.design-brief.v1", + "report_intent": "費用分析", + "catalog_guardrail": "financial-control", + "layout_recipe": {"mode": "kpi-first-dashboard", "sections": ["主管摘要"]}, + "chart_recipe": [{"id": "amount", "type": "bar", "purpose": "費用"}], + "table_recipe": [{"id": "detail", "type": "interactive-detail", "features": ["filter"]}], + "interaction_recipe": {"cross_filter": True, "drilldown": True, "evidence_drawer": "collapsed"}, + "visual_direction": {"density": "dense", "tone": "管理控制", "emphasis": ["variance"]}, + "embedded_data_policy": {"mode": "smart-tiered", "preview_rows": 200, "full_rows_threshold": 5000}, + } + + +def test_export_single_html_report_writes_self_contained_html_and_evidence(tmp_path: Path): + result = export_single_html_report(tmp_path, _package(), _brief()) + + html_path = Path(result["html_path"]) + manifest_path = tmp_path / "delivery" / "delivery-manifest.json" + html_text = html_path.read_text(encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + assert html_path.name == "report.html" + assert "" in html_text.lower() + assert "__WFERP_REPORT_PACKAGE__" in html_text + assert " + +""" + + +def _contains_secret(data: Any) -> bool: + text = json.dumps(data, ensure_ascii=False).lower() + return "password" in text or "connection_string" in text or "credential" in text + + +def export_single_html_report(output_root: str | Path, package: dict[str, Any], brief: dict[str, Any]) -> dict[str, Any]: + if _contains_secret(package): + return {"status": "error", "errors": ["credentials"]} + package_result = validate_report_package({**copy.deepcopy(package), "delivery_gate": {"allowed": True}}) + if "credentials" in package_result["errors"]: + return {"status": "error", "errors": package_result["errors"]} + + delivery = Path(output_root) / "delivery" + evidence = delivery / "evidence" + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "report-package.json").write_bytes(_json_bytes(package)) + (evidence / "report-design-brief.json").write_bytes(_json_bytes(brief)) + (evidence / "query.sql").write_text(str(package.get("sql", {}).get("text", "")) + "\n", encoding="utf-8") + + html_text = _html_document(package, brief) + html_bytes = html_text.encode("utf-8") + html_path = delivery / "report.html" + html_path.write_bytes(html_bytes) + manifest = { + "html_path": "report.html", + "html_sha256": _sha256_bytes(html_bytes), + "package_sha256": package.get("hashes", {}).get("package_sha256"), + "catalog_guardrail": package.get("catalog_guardrail"), + "row_count": package.get("data_profile", {}).get("row_count"), + "validator_status": "pass", + } + (delivery / "delivery-manifest.json").write_bytes(_json_bytes(manifest)) + return {"status": "exported", "html_path": str(html_path), "html_sha256": manifest["html_sha256"]} +``` + +- [ ] **Step 4: Add CLI subcommand** + +Add to `skill_scripts/cli_report_harness.py`: + +```python +def _export_single_html(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Export a verified report package as single HTML.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--package", required=True) + parser.add_argument("--brief", required=True) + parser.add_argument("--output-root", default="") + args = parser.parse_args(argv) + try: + output_root = Path(args.output_root) if args.output_root else Path(args.run_dir) + result = export_single_html_report(output_root, _load_json_arg(args.package), _load_json_arg(args.brief)) + if result["status"] == "error": + return _json_error("single_html_export_error", ", ".join(result["errors"])) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + return _json_error("single_html_export_error", str(exc)) + _write_stdout_json(result) + return 0 +``` + +Add import: + +```python +from skill_scripts.single_html_exporter import export_single_html_report +``` + +Add command: + +```python +"export-single-html": _export_single_html, +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +pytest tests/skill_scripts/test_single_html_exporter.py tests/skill_scripts/test_cli_report_harness.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skill_scripts/single_html_exporter.py tests/skill_scripts/test_single_html_exporter.py skill_scripts/cli_report_harness.py report_renderer/README.md tests/skill_scripts/test_cli_report_harness.py +git commit -m "feat: export single html report" +``` + +--- + +## Task 5: HTML Self Validator + +**Files:** +- Create: `skill_scripts/html_self_validator.py` +- Create: `tests/skill_scripts/test_html_self_validator.py` +- Modify: `skill_scripts/cli_report_harness.py` + +- [ ] **Step 1: Write failing tests for static HTML validation** + +Create `tests/skill_scripts/test_html_self_validator.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +from skill_scripts.html_self_validator import validate_single_html_static + + +def test_static_validator_accepts_self_contained_html(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ +

    費用分析

    + + """, + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result == {"valid": True, "errors": [], "network_references": []} + + +def test_static_validator_rejects_external_network_and_credentials(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ + + password=secret""", + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result["valid"] is False + assert "external_script" in result["errors"] + assert "credentials" in result["errors"] + assert result["network_references"] == ["https://cdn.example/app.js"] +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pytest tests/skill_scripts/test_html_self_validator.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'skill_scripts.html_self_validator'`. + +- [ ] **Step 3: Implement static validator** + +Create `skill_scripts/html_self_validator.py`: + +```python +from __future__ import annotations + +import re +from pathlib import Path + +NETWORK_PATTERN = re.compile(r"""(?:src|href)=["'](https?://[^"']+)["']""", re.IGNORECASE) + + +def validate_single_html_static(path: str | Path) -> dict[str, object]: + html = Path(path).read_text(encoding="utf-8") + errors: list[str] = [] + network = NETWORK_PATTERN.findall(html) + if " + + diff --git a/report_renderer/package-lock.json b/report_renderer/package-lock.json new file mode 100644 index 0000000..7254230 --- /dev/null +++ b/report_renderer/package-lock.json @@ -0,0 +1,2712 @@ +{ + "name": "wferp-report-renderer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wferp-report-renderer", + "version": "0.1.0", + "dependencies": { + "@testing-library/react": "^16.3.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^26.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.2.0" + }, + "devDependencies": {} + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/report_renderer/package.json b/report_renderer/package.json new file mode 100644 index 0000000..4b85e62 --- /dev/null +++ b/report_renderer/package.json @@ -0,0 +1,22 @@ +{ + "name": "wferp-report-renderer", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build", + "test": "vitest run --environment jsdom" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0", + "typescript": "^5.8.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vitest": "^3.2.0", + "jsdom": "^26.1.0", + "@testing-library/react": "^16.3.0" + }, + "devDependencies": {} +} diff --git a/report_renderer/src/App.tsx b/report_renderer/src/App.tsx new file mode 100644 index 0000000..86d0545 --- /dev/null +++ b/report_renderer/src/App.tsx @@ -0,0 +1,118 @@ +import CheckpointPage from "./components/CheckpointPage"; +import ReportPage from "./components/ReportPage"; + +export type TableRow = Record; + +export interface DataPreview { + rowCount: number; + columns: string[]; + rows: TableRow[]; +} + +export interface SqlReview { + title?: string; + sql: string; + validation?: { + readonly?: boolean; + blockedKeywords?: string[]; + executionStatus?: "not_executed" | "executed" | "blocked"; + }; + schemaMapping?: FieldMapping[]; + relationshipPath?: string[]; + safetyChecks?: string[]; + executionEnvironment?: string; +} + +export interface ReportTypeChoice { + id: string; + label: string; + description: string; +} + +export interface FieldFormulaReviewPayload { + fields?: FieldFormulaItem[]; + formulas?: FieldFormulaItem[]; +} + +export interface FieldFormulaItem { + label: string; + source?: string; + expression?: string; + confirmation?: string; +} + +export interface FieldMapping { + field: string; + table: string; + column: string; + note?: string; +} + +export interface AggregateCheck { + label: string; + expected?: string | number; + actual?: string | number; + status: "pass" | "warning" | "fail"; + note?: string; +} + +export interface ValidatorEvidence { + validator: string; + status: "pass" | "warning" | "fail"; + message: string; + evidencePath?: string; +} + +export interface CheckpointPayload { + kind: "checkpoint"; + checkpointId?: string; + title: string; + step?: string; + confirmUrl?: string; + requirementSummary?: string; + fieldFormulaReview?: FieldFormulaReviewPayload; + sqlReview?: SqlReview; + dataPreview?: DataPreview; + aggregateChecks?: AggregateCheck[]; + reportTypes?: ReportTypeChoice[]; + exceptions?: string[]; + validatorEvidence?: ValidatorEvidence[]; + actions?: string[]; +} + +export interface ReportSection { + type: "chart" | "table" | "analysis" | "recommendations"; + title: string; + body?: string; + items?: string[]; +} + +export interface FinalReportPayload { + kind: "report"; + title: string; + subtitle?: string; + options?: { + charts?: boolean; + tables?: boolean; + analysis?: boolean; + recommendations?: boolean; + }; + sections?: ReportSection[]; + dataPreview?: DataPreview; + validatorEvidence?: ValidatorEvidence[]; + validatorEvidenceSummary?: ValidatorEvidence[]; +} + +export type ReportPayload = CheckpointPayload | FinalReportPayload; + +interface AppProps { + payload: ReportPayload; +} + +export default function App({ payload }: AppProps) { + if (payload.kind === "report") { + return ; + } + + return ; +} diff --git a/report_renderer/src/components/ActionBar.tsx b/report_renderer/src/components/ActionBar.tsx new file mode 100644 index 0000000..a9b0ef6 --- /dev/null +++ b/report_renderer/src/components/ActionBar.tsx @@ -0,0 +1,113 @@ +import React from "react"; + +type ActionState = "idle" | "submitting" | "confirmed" | "failed" | "preview"; + +interface ActionBarProps { + actions?: string[]; + checkpointId?: string; + confirmUrl?: string; + selectedOptions: Record; +} + +function statusText(state: ActionState) { + if (state === "submitting") { + return "送出中"; + } + if (state === "confirmed") { + return "已送出確認"; + } + if (state === "failed") { + return "送出失敗,請重試"; + } + if (state === "preview") { + return "此頁僅供預覽,未連接確認端點"; + } + return ""; +} + +export default function ActionBar({ + actions = [], + checkpointId = "", + confirmUrl, + selectedOptions, +}: ActionBarProps) { + const useReactState = (React as unknown as { + useState: (initial: T) => [T, (value: T) => void]; + }).useState; + const useReactRef = (React as unknown as { + useRef: (initial: T) => { current: T }; + }).useRef; + const [comment, setComment] = useReactState(""); + const [state, setState] = useReactState(confirmUrl ? "idle" : "preview"); + const submittingRef = useReactRef(false); + + if (actions.length === 0) { + return null; + } + + async function submitAction(action: string) { + if (!confirmUrl) { + setState("preview"); + return; + } + if (submittingRef.current) { + return; + } + submittingRef.current = true; + setState("submitting"); + try { + const response = await fetch(confirmUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + checkpointId, + comment, + selectedOptions, + }), + }); + + if (!response.ok) { + throw new Error(`Confirmation failed: ${response.status}`); + } + setState("confirmed"); + } catch { + setState("failed"); + } finally { + submittingRef.current = false; + } + } + + return ( +
    +
    ", + "", + "", + "", + "", + "", + ] + ) + + +_COMPANION_CSS = """ +*{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f7f9;color:#17202a}body{line-height:1.5}.topbar{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background:#fff;border-bottom:1px solid #d9dee7}.topbar h1{font-size:20px;margin:2px 0 0}.eyebrow{margin:0;color:#667085;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:700}.run-chip{font-size:12px;border:1px solid #d0d5dd;border-radius:999px;padding:6px 10px;background:#f9fafb;color:#475467}.shell{display:grid;grid-template-columns:280px minmax(0,1fr);gap:24px;padding:24px}.side-rail{position:sticky;top:82px;align-self:start}.progress{list-style:none;margin:0;padding:0;display:grid;gap:8px}.step{border:1px solid #d0d5dd;border-radius:8px;background:#fff;color:#667085}.step a,.step-disabled{display:flex;gap:10px;align-items:center;width:100%;padding:10px;color:inherit;text-decoration:none}.step a:hover{background:#f8fafc}.step.disabled{opacity:.48}.step-index{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#eef2f6;font-size:12px;flex:0 0 26px}.step strong{font-size:13px}.step.done{border-color:#9bd3b0;color:#176b3a}.step.done .step-index{background:#def7e7}.step.current{border-color:#2f6fed;box-shadow:0 0 0 3px rgba(47,111,237,.12);color:#17202a}.step.current .step-index{background:#2f6fed;color:#fff}.content{display:grid;gap:18px;max-width:1180px}.panel{background:#fff;border:1px solid #d9dee7;border-radius:10px;padding:18px;box-shadow:0 1px 2px rgba(16,24,40,.04)}.hero h2{font-size:28px;margin:4px 0 8px}.subtitle{color:#475467;margin:0}.status-line{margin-top:14px;padding:10px 12px;border-radius:8px;background:#f1f5ff;color:#2446a4;font-weight:600}h2{font-size:18px;margin:0 0 12px}h3{font-size:15px;margin:16px 0 8px}.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px}.kpi-card{border:1px solid #d9dee7;border-radius:8px;padding:14px;background:#fbfcfe}.kpi-label{font-size:12px;color:#667085}.kpi-value{font-size:24px;font-weight:750;margin-top:4px}.kv-grid{display:grid;gap:8px}.kv-row{display:grid;grid-template-columns:minmax(150px,240px) minmax(0,1fr);gap:12px;padding:9px 0;border-bottom:1px solid #eef1f5}.kv-row dt{font-weight:700;color:#344054}.kv-row dd{margin:0;min-width:0}.kv-value{overflow-wrap:anywhere}.nested-card{border:1px solid #e5e7ec;border-radius:8px;padding:12px;margin:8px 0;background:#fbfcfe}.clean-list{margin:0;padding-left:20px}.clean-list li{margin:6px 0}.table-wrap{overflow:auto;border:1px solid #d9dee7;border-radius:8px}table{border-collapse:collapse;width:100%;font-size:13px;background:#fff}th,td{padding:9px 10px;border-bottom:1px solid #edf0f5;text-align:left;vertical-align:top;white-space:nowrap}th{background:#f8fafc;color:#344054;font-weight:750}.table-note,.muted{color:#667085}.code-block{margin:0;white-space:pre-wrap;overflow:auto;background:#111827;color:#f9fafb;border-radius:8px;padding:14px;font-size:13px}.badge{display:inline-flex;border-radius:999px;padding:3px 8px;font-size:12px;font-weight:700}.badge-pass{background:#dcfae6;color:#067647}.badge-fail{background:#fee4e2;color:#b42318}.badge-warning{background:#fef0c7;color:#b54708}.badge-neutral{background:#eef2f6;color:#475467}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.card{border:1px solid #d9dee7;border-radius:8px;background:#fbfcfe;cursor:pointer}.card:hover,.card.selected{border-color:#2f6fed;box-shadow:0 0 0 3px rgba(47,111,237,.12)}.card-body{padding:14px}.card h3{margin:0 0 6px}.card p{margin:0;color:#667085}.report-preview{border:1px solid #b8c7dc;border-radius:10px;background:#eef3f7;padding:10px}.preview-toolbar{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:6px 4px 12px;color:#344054;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.preview-report-shell{background:#fff;border:1px solid #d6dde7;border-radius:8px;padding:18px;box-shadow:0 10px 28px rgba(23,32,42,.08)}.preview-report-header{display:grid;gap:6px;border-bottom:1px solid #e6ebf2;padding-bottom:14px;margin-bottom:14px}.preview-report-header p{margin:0;color:#667085;font-size:12px;font-weight:800;text-transform:uppercase}.preview-report-header h3{font-size:24px;margin:0}.feature-tag{display:inline-flex;margin:3px 5px 3px 0;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;background:#f8fafc;color:#334155;font-size:12px;font-weight:700}.preview-layout-map{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:14px 0}.layout-zone{min-height:58px;border:1px dashed #9fb1c7;border-radius:8px;background:#f8fafc;display:grid;place-items:center;color:#475467;font-weight:750}.preview-kpis{margin:14px 0}.preview-chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin:14px 0}.preview-chart,.preview-table-component,.preview-copy{border:1px solid #d9dee7;border-radius:8px;background:#fbfcfe;padding:14px}.preview-card-head{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:8px}.preview-card-head span{color:#667085;font-size:12px;font-weight:800;text-transform:uppercase}.preview-chart p{min-height:38px;margin:0 0 10px;color:#667085}.chart-mock{height:142px;display:flex;align-items:end;gap:12px;border:1px solid #e4e9f1;border-radius:8px;background:linear-gradient(180deg,#fff,#f8fafc);padding:12px 12px 24px}.chart-bar-wrap{display:grid;grid-template-rows:auto 1fr auto;align-items:end;justify-items:center;min-width:42px;flex:1;height:100%}.chart-value{font-size:11px;color:#667085}.chart-bar-fill{width:100%;max-width:46px;background:#286b7a;border-radius:5px 5px 0 0}.muted-fill{background:#91a4b7}.alert-fill{background:#c45f3c}.chart-x-label{font-size:11px;color:#475467;margin-top:6px;max-width:76px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview-table-grid{display:grid;gap:12px}.feature-row{margin-bottom:8px}.preview-copy{margin-top:12px}.preview-copy h4{margin:0 0 8px}.risk-list{display:grid;gap:8px}.risk-item{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid #f2d596;background:#fff9eb;border-radius:8px}.sticky-actions{position:sticky;bottom:0;border-color:#b9c7e8}.comment-label{display:block;font-weight:700;margin-bottom:6px}textarea{width:100%;min-height:78px;resize:vertical;border:1px solid #d0d5dd;border-radius:8px;padding:10px;font:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}.action-button{border:0;border-radius:8px;background:#2f6fed;color:#fff;font-weight:750;padding:10px 14px;cursor:pointer}.action-button:hover{background:#245bd0}.action-button.secondary{background:#eef2f6;color:#344054}@media(max-width:860px){.shell{grid-template-columns:1fr}.side-rail{position:static}.topbar{align-items:flex-start;gap:10px;flex-direction:column}.kv-row{grid-template-columns:1fr}th,td{white-space:normal}.chart-mock{overflow:auto}.preview-report-header h3{font-size:20px}} +""" + +_COMPANION_CSS += """ +.preview-controls{border:1px solid #c8d4e3;border-radius:8px;background:#fff;padding:12px;margin-bottom:12px} +.control-group,.chart-control-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px} +.control-field{display:grid;gap:5px} +.control-field span{font-size:12px;font-weight:800;color:#475467} +.control-field input,.control-field select{border:1px solid #cbd5e1;border-radius:7px;background:#fff;padding:8px;font:inherit} +.hidden-control{display:none} +.sticky-actions select{width:100%;border:1px solid #d0d5dd;border-radius:8px;padding:10px;font:inherit;background:#fff} +.chart-render-note{font-size:12px;color:#475467;background:#eef6f6;border:1px solid #d1e3e4;border-radius:7px;padding:7px;margin:8px 0} +""" + + +_COMPANION_JS = """ +function toggleSelect(el){ + const container = el.closest('.cards'); + if(container){ container.querySelectorAll('.card').forEach(card => card.classList.remove('selected')); } + el.classList.add('selected'); +} + +function chartRenderNote(type){ + const notes = { + 'combo': '同一圖中比較實際、預算與差異,適合財務管控。', + 'bar': '用長條排序主要費用驅動項目,適合找最大影響來源。', + 'stacked-bar': '用堆疊長條看費用組成,適合比較分類占比。', + 'line': '用折線看期間趨勢,適合有月份或日期欄位時使用。' + }; + return notes[type] || '用目前資料欄位產生基本視覺比較。'; +} + +function layoutSections(mode, fallback){ + const map = { + 'kpi-first-dashboard': ['executive-summary', 'kpi-overview', 'expense-trend', 'exception-review', 'data-table'], + 'four-chart-grid': ['kpi-overview', 'chart-grid-2x2', 'exception-review', 'data-table'], + 'executive-one-page': ['decision-summary', 'kpi-strip', 'key-chart', 'recommendations'], + 'detail-audit': ['query-scope', 'detail-table', 'exception-list', 'audit-notes'] + }; + return map[mode] || fallback || ['report-body']; +} + +function readVisualSelection(){ + const root = document.querySelector('[data-preview-controls]'); + if (!root) return {}; + const chartCount = Number(root.querySelector('[data-chart-count]')?.value || 0); + const layoutMode = root.querySelector('[data-layout-mode]')?.value || ''; + const charts = []; + for (let index = 0; index < chartCount; index += 1) { + charts.push({ + id: root.querySelector(`[data-chart-id="${index}"]`)?.value || `chart-${index + 1}`, + type: root.querySelector(`[data-chart-type="${index}"]`)?.value || 'bar' + }); + } + return { layoutMode, chartCount, charts }; +} + +function refreshPreview(){ + const root = document.querySelector('[data-preview-controls]'); + const preview = document.querySelector('.report-preview'); + if (!root || !preview) return; + const selection = readVisualSelection(); + root.querySelectorAll('[data-chart-control]').forEach((control) => { + const index = Number(control.dataset.chartControl || 0); + control.classList.toggle('hidden-control', index >= selection.chartCount); + }); + let fallback = []; + try { + fallback = JSON.parse(root.querySelector('[data-layout-sections]')?.value || '[]'); + } catch (_error) { + fallback = []; + } + const layout = preview.querySelector('.preview-layout-map'); + if (layout) { + layout.innerHTML = layoutSections(selection.layoutMode, fallback) + .map((section) => `
    ${section}
    `) + .join(''); + } + const chartGrid = preview.querySelector('.preview-chart-grid'); + if (chartGrid) { + chartGrid.innerHTML = selection.charts.map((chart) => ` +
    +
    ${chart.id}${chart.type}
    +

    依目前 prompt 與選擇產生 ${chart.type} 圖表預覽。

    +
    呈現方式:${chartRenderNote(chart.type)}
    + +
    + `).join(''); + } +} + +function selectedOptions(){ + const options = {}; + document.querySelectorAll('[data-choice].selected').forEach((el) => { + options.choice = el.dataset.choice || ''; + }); + const visualSelection = readVisualSelection(); + if (visualSelection.layoutMode) { + options.visualSelection = visualSelection; + } + const scope = document.getElementById('change-scope')?.value || ''; + if (scope) { + options.changeScope = scope; + options.requiresRerender = true; + options.targetUserStep = Number(document.querySelector('[data-user-step]')?.dataset.userStep || 0); + } + const acceptedRisks = Array.from(document.querySelectorAll('[data-risk]:checked')).map((el) => { + const risk = el.getAttribute('data-risk') || ''; + const checkpoint = document.querySelector('[data-checkpoint]')?.dataset.checkpoint || ''; + if (checkpoint === 'final_review' && !risk.includes(':')) { + return risk; + } + return risk; + }); + if (acceptedRisks.length) { + options.acceptedResidualRisks = acceptedRisks; + } + return options; +} + +async function confirmCheckpoint(action){ + const root = document.querySelector('[data-confirm-url]'); + const status = document.getElementById('confirmation-status'); + const confirmUrl = root.dataset.confirmUrl; + const checkpoint = root.dataset.checkpoint; + const comment = document.getElementById('confirmation-comment')?.value || ''; + status.textContent = '送出確認中...'; + try { + const response = await fetch(confirmUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + checkpointId: checkpoint, + action, + comment, + selectedOptions: selectedOptions() + }) + }); + const body = await response.json(); + if (!response.ok) { + status.textContent = '確認失敗:' + (body.error || body.status || response.status); + status.classList.add('error'); + return; + } + status.textContent = '已確認:' + action; + status.classList.remove('error'); + status.classList.add('confirmed'); + } catch (error) { + status.textContent = '確認失敗:' + error; + status.classList.add('error'); + } +} + +document.addEventListener('click', (event) => { + const choice = event.target.closest('[data-choice]'); + if (choice) toggleSelect(choice); + const refresh = event.target.closest('[data-refresh-preview]'); + if (refresh) refreshPreview(); + const action = event.target.closest('[data-action]'); + if (action) confirmCheckpoint(action.dataset.action); +}); + +document.addEventListener('change', (event) => { + if (event.target.closest('[data-preview-controls]')) refreshPreview(); +}); +""" + + +class CheckpointCompanionServer: + @staticmethod + @contextmanager + def serve( + run_dir: str | Path, + host: str = "127.0.0.1", + port: int = 0, + ) -> Iterator[RunningCheckpointServer]: + run_path = Path(run_dir) + + class Handler(BaseHTTPRequestHandler): + def _json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _html(self, status: int, body: str) -> None: + content = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def _path_parts(self) -> list[str]: + return [part for part in urlparse(self.path).path.split("/") if part] + + def _read_json(self) -> dict[str, Any] | None: + try: + content_length = int(self.headers.get("Content-Length", "")) + except ValueError: + self._json(400, {"status": "bad_request"}) + return None + + if content_length > MAX_REQUEST_BYTES: + self.rfile.read(min(content_length, MAX_DRAIN_BYTES)) + self._json(413, {"status": "request_too_large"}) + return None + + try: + raw_body = self.rfile.read(content_length) + payload = json.loads(raw_body.decode("utf-8")) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + self._json(400, {"status": "bad_request"}) + return None + if not isinstance(payload, dict): + self._json(400, {"status": "bad_request"}) + return None + return payload + + def do_GET(self) -> None: + parts = self._path_parts() + if len(parts) != 4 or parts[:1] != ["runs"] or parts[2] != "checkpoints": + self._json(404, {"status": "not_found"}) + return + + run_id = parts[1] + requested_checkpoint = parts[3] + if run_path.name != run_id: + self._json(409, {"status": "wrong_run"}) + return + + state = load_run_state(run_path) + checkpoints = state.get("checkpoints", []) + if not checkpoints: + self._json(404, {"status": "no_checkpoint"}) + return + + if requested_checkpoint == "current": + checkpoint_entry = checkpoints[-1] + else: + checkpoint_entry = next( + ( + entry for entry in checkpoints + if isinstance(entry, Mapping) + and entry.get("checkpoint") == requested_checkpoint + ), + None, + ) + if checkpoint_entry is None: + self._json(404, {"status": "checkpoint_not_found"}) + return + + checkpoint = checkpoint_entry["checkpoint"] + definition = CHECKPOINT_DEFINITIONS[checkpoint] + checkpoint_file = run_path / checkpoint_entry["file"] + try: + checkpoint_payload = json.loads(checkpoint_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + self._json(500, {"status": "checkpoint_unreadable"}) + return + + self._html( + 200, + _render_checkpoint_page( + run_path=run_path, + run_id=run_id, + checkpoint=checkpoint, + definition=definition, + checkpoint_payload=checkpoint_payload, + state=state, + ), + ) + + def do_POST(self) -> None: + parts = self._path_parts() + if ( + len(parts) != 6 + or parts[:2] != ["api", "runs"] + or parts[3] != "checkpoints" + or parts[5] != "confirm" + ): + self._json(404, {"status": "not_found"}) + return + + run_id = parts[2] + checkpoint = parts[4] + if run_path.name != run_id: + self._json(409, {"status": "wrong_run"}) + return + + try: + payload = self._read_json() + if payload is None: + return + action = payload["action"] + if payload.get("checkpointId", checkpoint) != checkpoint: + self._json(409, {"status": "wrong_checkpoint"}) + return + + harness = ReportHarness(run_path) + selected_options = payload.get("selectedOptions") + harness.confirm( + checkpoint, + action, + selected_options=selected_options if isinstance(selected_options, dict) else None, + comment=str(payload.get("comment", "")), + ) + definition = CHECKPOINT_DEFINITIONS[checkpoint] + confirmation_path = run_path / "checkpoints" / definition["file"].replace( + ".json", ".confirmation.json" + ) + confirmation = json.loads(confirmation_path.read_text(encoding="utf-8")) + append_audit_event( + run_path, + "checkpoint_confirmed", + {"checkpoint": checkpoint, "action": action}, + ) + except (KeyError, json.JSONDecodeError): + self._json(400, {"status": "bad_request"}) + return + except (ReportHarnessError, ValueError) as error: + self._json(400, {"status": "rejected", "error": str(error)}) + return + + self._json(200, {"status": "confirmed", "confirmation": confirmation}) + + def log_message(self, format: str, *args: object) -> None: + return + + httpd = CheckpointHTTPServer((host, port), Handler) + thread = Thread(target=httpd.serve_forever, daemon=True) + thread.start() + running = RunningCheckpointServer( + httpd=httpd, + thread=thread, + base_url=f"http://{host}:{httpd.server_address[1]}", + ) + try: + yield running + finally: + httpd.shutdown() + thread.join(timeout=5) + httpd.server_close() diff --git a/skill_scripts/cli_generate_select.py b/skill_scripts/cli_generate_select.py new file mode 100644 index 0000000..f0a211b --- /dev/null +++ b/skill_scripts/cli_generate_select.py @@ -0,0 +1,154 @@ +import argparse +from dataclasses import asdict, replace +import json +from pathlib import Path + +from skill_scripts.database_client import DatabaseClient, DatabaseConfig +from skill_scripts.data_dictionary import build_alias_index, build_field_index +from skill_scripts.execution_validator import AggregateExpectation, ExecutionExpectation +from skill_scripts.governed_query import run_governed_query +from skill_scripts.relationship_graph import build_primary_key_map, infer_relationships +from skill_scripts.schema_loader import load_schema_bundle +from skill_scripts.sql_router import RoutingOptions, route_generate_sql + + +def _artifacts_dir() -> Path: + return Path(__file__).resolve().parent / "artifacts" + + +def _write_json(path: Path, data): + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def build_artifacts(source: str): + bundle = load_schema_bundle(source) + out = _artifacts_dir() + out.mkdir(parents=True, exist_ok=True) + + field_index = build_field_index(bundle["fields"]) + alias_index = build_alias_index(bundle["fields"]) + pk_map = build_primary_key_map(bundle["index_keys"]) + edges = infer_relationships(bundle["fields"], bundle["index_keys"]) + + _write_json(out / "schema_bundle.json", bundle) + _write_json(out / "field_index.json", field_index) + _write_json(out / "alias_index.json", alias_index) + _write_json(out / "primary_key_map.json", pk_map) + _write_json(out / "relationship_edges.json", edges) + + +def _parse_required_columns(raw: str) -> list[str]: + return [part.strip() for part in str(raw or "").split(",") if part.strip()] + + +def _parse_aggregate_expectations(raw: str) -> list[AggregateExpectation]: + expectations: list[AggregateExpectation] = [] + # format: sum:MK006:1000:0.1,min:MK006:10 + for chunk in [part.strip() for part in str(raw or "").split(",") if part.strip()]: + parts = [p.strip() for p in chunk.split(":")] + if len(parts) < 3: + continue + operation, column, expected = parts[0], parts[1], float(parts[2]) + tolerance = float(parts[3]) if len(parts) >= 4 else 0.0 + expectations.append( + AggregateExpectation( + operation=operation, + column=column, + expected_value=expected, + tolerance=tolerance, + ) + ) + return expectations + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--prompt", default="") + parser.add_argument("--source", default="_Source") + parser.add_argument("--build-artifacts", action="store_true") + parser.add_argument("--mode", choices=["rule", "shadow", "llm-first"], default="llm-first") + parser.add_argument("--llm-provider", default="codex") + parser.add_argument("--llm-model", default="none") + parser.add_argument("--llm-timeout-sec", type=float, default=30.0) + parser.add_argument("--llm-min-confidence", type=float, default=0.6) + parser.add_argument("--llm-repair-attempts", type=int, default=2) + + parser.add_argument("--validate-execution", action="store_true") + parser.add_argument("--allow-non-test-db-execution", action="store_true") + parser.add_argument("--governed-query-json", action="store_true") + parser.add_argument("--sample-rows-limit", type=int, default=5) + parser.add_argument("--required-columns", default="") + parser.add_argument("--min-rows", type=int, default=0) + parser.add_argument("--max-rows", type=int, default=None) + parser.add_argument("--aggregate-checks", default="") + + parser.add_argument("--db-driver", default=None) + parser.add_argument("--db-connection-string", default=None) + parser.add_argument("--db-auth-mode", default=None) + parser.add_argument("--db-env", default=None) + args = parser.parse_args() + + if args.build_artifacts: + build_artifacts(args.source) + print("OK:ARTIFACTS_BUILT") + return + + bundle = load_schema_bundle(args.source) + try: + db_config = DatabaseConfig.from_env().with_overrides( + driver=args.db_driver, + connection_string=args.db_connection_string, + auth_mode=args.db_auth_mode, + env=args.db_env, + ) + + aggregate_expectations = _parse_aggregate_expectations(args.aggregate_checks) + execution_expectation = ExecutionExpectation( + required_columns=_parse_required_columns(args.required_columns), + min_rows=args.min_rows, + max_rows=args.max_rows, + aggregates=aggregate_expectations, + ) + + validate_execution = bool(args.validate_execution or args.governed_query_json) + routing_options = RoutingOptions( + mode=args.mode, + llm_provider=args.llm_provider, + llm_model=args.llm_model, + llm_timeout_sec=args.llm_timeout_sec, + min_confidence=args.llm_min_confidence, + llm_repair_attempts=args.llm_repair_attempts, + validate_execution=validate_execution, + execution_expectation=execution_expectation, + allow_non_test_execution=args.allow_non_test_db_execution, + ) + + db_client = DatabaseClient(db_config) if validate_execution else None + if args.governed_query_json: + governed_result = run_governed_query( + prompt=args.prompt, + bundle=bundle, + options=replace(routing_options, validate_execution=True), + db_client=db_client, + sample_rows_limit=args.sample_rows_limit, + ) + print(json.dumps(asdict(governed_result), ensure_ascii=False)) + return + sql, meta = route_generate_sql( + prompt=args.prompt, + bundle=bundle, + options=routing_options, + db_client=db_client, + ) + print(sql) + print(f"ROUTE:{meta['route']} REASON:{meta['reason']}") + if "candidate_sql" in meta: + print(f"CANDIDATE_SQL:{meta['candidate_sql']}") + except ValueError as exc: + print(f"ERROR:{exc}") + except RuntimeError as exc: + print(f"ERROR:{exc}") + + +if __name__ == "__main__": + main() diff --git a/skill_scripts/cli_report_harness.py b/skill_scripts/cli_report_harness.py new file mode 100644 index 0000000..bcbfacd --- /dev/null +++ b/skill_scripts/cli_report_harness.py @@ -0,0 +1,1315 @@ +from __future__ import annotations + +import argparse +from dataclasses import asdict +import json +import os +from pathlib import Path +import sqlite3 +import sys +import time +from typing import Any + +from skill_scripts.dynamic_design_brief import build_design_brief +from skill_scripts.dynamic_design_brief import validate_design_brief +from skill_scripts.excel_intake import build_excel_confirmation_payload, parse_excel_requirement +from skill_scripts.html_self_validator import validate_single_html_static +from skill_scripts.llm_workbook_classifier import classify_workbook_with_llm +from skill_scripts.report_gate_checks import evaluate_delivery_artifacts +from skill_scripts.report_gate_checks import scan_run_text_artifacts +from skill_scripts.sqlite_enrichment import run_enrichment +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace +from skill_scripts.report_catalog import build_report_selection_payload +from skill_scripts.report_catalog import get_report_design_defaults +from skill_scripts.report_catalog import list_report_designs +from skill_scripts.report_harness import ReportHarness +from skill_scripts.report_harness import ReportHarnessError +from skill_scripts.report_harness_state import load_run_state +from skill_scripts.report_harness_state import CHECKPOINT_DEFINITIONS +from skill_scripts.report_scaffold import scaffold_report_workspace +from skill_scripts.report_scaffold import validate_generated_report_section +from skill_scripts.schema_loader import load_schema_bundle +from skill_scripts.single_html_exporter import export_single_html_report +from skill_scripts.sql_router import RoutingOptions, route_generate_sql +from skill_scripts.style_replay import detect_replay_adjustments +from skill_scripts.user_step_payload import build_user_step_payload +from skill_scripts.visual_checkpoint import build_visual_checkpoint_payload +from skill_scripts.visual_checkpoint import render_visual_checkpoint_html +from skill_scripts.workbook_lookup_importer import import_lookup_sheet +from skill_scripts.report_scaffold import write_generated_report_section + +DEFAULT_REPORT_SECTIONS = [ + "executive-summary", + "kpi-overview", + "exception-review", + "data-table", + "recommendations", +] + +DESIGN_SECTION_DEFAULTS = { + "financial-control": DEFAULT_REPORT_SECTIONS, +} + + +def _write_stdout_json(data: dict[str, Any]) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def _write_stderr_json(data: dict[str, Any]) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2), file=sys.stderr) + + +def _json_error(code: str, message: str) -> int: + _write_stderr_json({"status": "error", "code": code, "message": message}) + return 2 + + +def _default_scaffold_template_dir() -> Path: + return Path.home() / ".codex" / "skills" / "wferp-report" / "assets" / "scaffold-template" + + +def _load_json_arg(value: str) -> dict[str, Any]: + path = Path(value) + if path.exists(): + loaded = json.loads(path.read_text(encoding="utf-8-sig")) + else: + loaded = json.loads(value) + if not isinstance(loaded, dict): + raise ValueError("JSON payload must be an object") + return loaded + + +def _load_json_list_arg(value: str) -> list[dict[str, Any]]: + path = Path(value) + if path.exists(): + loaded = json.loads(path.read_text(encoding="utf-8-sig")) + else: + loaded = json.loads(value) + if not isinstance(loaded, list) or not all(isinstance(item, dict) for item in loaded): + raise ValueError("JSON payload must be a list of objects") + return loaded + + +def _load_json_arg_or_empty(value: str | None) -> dict[str, Any]: + return _load_json_arg(value) if value else {} + + +def _payload_or_checkpoint_payload(value: dict[str, Any]) -> dict[str, Any]: + payload = value.get("payload") + if isinstance(payload, dict) and "checkpoint" in value: + return payload + return value + + +def _load_text_arg(*, inline: str | None, file_path: str | None) -> str: + if bool(inline) == bool(file_path): + raise ValueError("Provide exactly one of --code or --code-file") + if file_path: + return Path(file_path).read_text(encoding="utf-8") + return inline or "" + + +def _write_run_json(run_dir: Path, relative_path: str, payload: dict[str, Any]) -> None: + path = run_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _write_run_text(run_dir: Path, relative_path: str, text: str) -> None: + path = run_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _slugify_section(value: str) -> str: + slug = "".join(char.lower() if char.isalnum() and char.isascii() else "-" for char in value) + return "-".join(part for part in slug.split("-") if part) + + +def _sections_for_design(design: str) -> list[str]: + defaults = get_report_design_defaults(design) + sections = [str(section) for section in defaults["sections"] if str(section).strip()] + if sections: + return sections + + if design in DESIGN_SECTION_DEFAULTS: + return list(DESIGN_SECTION_DEFAULTS[design]) + + sections: list[str] = [] + for profile in list_report_designs(): + if profile["id"] != design: + continue + for section in profile.get("required_sections", []): + slug = _slugify_section(str(section)) + if slug: + sections.append(slug) + break + + for fallback in DEFAULT_REPORT_SECTIONS: + if fallback not in sections: + sections.append(fallback) + if len(sections) >= 5: + break + return sections[:5] + + +def _scaffold_report(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Scaffold a per-run WFERP React report workspace.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--design", default="") + parser.add_argument("--template-dir", type=Path, default=_default_scaffold_template_dir()) + parser.add_argument("--force", action="store_true") + args = parser.parse_args(argv) + + run_dir = Path(args.run_dir) + try: + state = load_run_state(run_dir) + except FileNotFoundError: + state = {} + design = args.design or state.get("report_design") or "financial-control" + payload = { + "run": state, + "approved_query_result": state.get("execution_result_summary") or {"rows": []}, + } + try: + result = scaffold_report_workspace( + run_dir=run_dir, + template_dir=args.template_dir, + sections=_sections_for_design(design), + payload=payload, + force=args.force, + ) + except (FileExistsError, FileNotFoundError, ValueError) as exc: + _write_stderr_json( + { + "status": "error", + "code": "scaffold_error", + "message": str(exc), + } + ) + return 2 + _write_stdout_json( + { + "status": "scaffolded", + "section_count": result["section_count"], + "run_dir": result["run_dir"], + "design": design, + } + ) + return 0 + + +def _serve_checkpoint(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Serve WFERP checkpoint companion confirmations.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + args = parser.parse_args(argv) + + from skill_scripts.checkpoint_companion import CheckpointCompanionServer + + run_dir = Path(args.run_dir) + if not (run_dir / "state.json").exists(): + return _json_error("run_not_found", f"Report run does not exist: {run_dir}") + with CheckpointCompanionServer.serve(run_dir, host=args.host, port=args.port) as server: + _write_stdout_json( + { + "status": "serving", + "url": f"{server.base_url}/runs/{run_dir.name}/checkpoints/current", + } + ) + try: + server.thread.join() + except KeyboardInterrupt: + return 0 + return 0 + + +def _confirmation_path(run_dir: Path, checkpoint: str) -> Path: + definition = CHECKPOINT_DEFINITIONS[checkpoint] + filename = definition["file"].replace(".json", ".confirmation.json") + return run_dir / "checkpoints" / filename + + +def _read_confirmation_payload(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Confirmation payload must be an object") + return payload + + +def _wait_confirmation(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Wait until a checkpoint confirmation is written.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--timeout-seconds", type=float, default=300.0) + parser.add_argument("--poll-interval-seconds", type=float, default=0.5) + parser.add_argument("--allow-existing", action="store_true") + parser.add_argument("--fresh-only", action="store_true") + args = parser.parse_args(argv) + + run_dir = Path(args.run_dir) + if not (run_dir / "state.json").exists(): + return _json_error("run_not_found", f"Report run does not exist: {run_dir}") + if args.checkpoint not in CHECKPOINT_DEFINITIONS: + return _json_error("unknown_checkpoint", f"Unknown checkpoint: {args.checkpoint}") + if args.timeout_seconds < 0: + return _json_error("invalid_timeout", "--timeout-seconds must be >= 0") + if args.poll_interval_seconds <= 0: + return _json_error("invalid_poll_interval", "--poll-interval-seconds must be > 0") + + path = _confirmation_path(run_dir, args.checkpoint) + started_wall_time = time.time() + deadline = time.monotonic() + args.timeout_seconds + + while True: + if path.exists(): + stat = path.stat() + if args.allow_existing or not args.fresh_only or stat.st_mtime >= started_wall_time: + try: + confirmation = _read_confirmation_payload(path) + except (json.JSONDecodeError, ValueError) as exc: + return _json_error("confirmation_read_error", str(exc)) + _write_stdout_json( + { + "status": "confirmed", + "checkpoint": args.checkpoint, + "action": confirmation.get("action", ""), + "comment": confirmation.get("comment", ""), + "selectedOptions": confirmation.get("selectedOptions", {}), + "run_id": confirmation.get("run_id", ""), + "checkpoint_id": confirmation.get("checkpoint_id", args.checkpoint), + "payload_hash": confirmation.get("payload_hash", ""), + "confirmation_id": confirmation.get("confirmation_id", ""), + "created_at": confirmation.get("created_at", ""), + "confirmation_file": str(path), + } + ) + return 0 + + if time.monotonic() >= deadline: + _write_stderr_json( + { + "status": "error", + "code": "confirmation_timeout", + "message": f"Timed out waiting for checkpoint confirmation: {args.checkpoint}", + "checkpoint": args.checkpoint, + "confirmation_file": str(path), + } + ) + return 2 + time.sleep(min(args.poll_interval_seconds, max(0.0, deadline - time.monotonic()))) + + +def _ensure_harness(args: argparse.Namespace) -> ReportHarness: + run_dir = Path(args.run_dir) + if (run_dir / "state.json").exists(): + return ReportHarness(run_dir) + + if not args.prompt: + raise SystemExit("--prompt is required when creating a new report run") + run_id = run_dir.name + run_root = run_dir.parent if str(run_dir.parent) else Path(".") + return ReportHarness.create( + run_root, + run_id=run_id, + prompt=args.prompt, + input_files=[str(path) for path in args.input_file], + ) + + +def _open_harness(run_dir: str | Path) -> ReportHarness: + path = Path(run_dir) + if not (path / "state.json").exists(): + raise ReportHarnessError(f"Report run does not exist: {path}") + return ReportHarness(path) + + +def _quote_sqlite_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _workspace_from_manifest(run_dir: Path, manifest_path: str | Path) -> SQLiteRunWorkspace: + path = Path(manifest_path) + if not path.exists(): + raise FileNotFoundError(f"SQLite manifest does not exist: {path}") + manifest = json.loads(path.read_text(encoding="utf-8")) + return SQLiteRunWorkspace( + run_dir=run_dir, + sqlite_db_path=Path(manifest["sqlite_db_path"]), + run_prefix=str(manifest["run_prefix"]), + raw_table=str(manifest["raw_table"]), + enriched_table=str(manifest["enriched_table"]), + manifest_path=path, + ) + + +def _workspace_from_state(harness: ReportHarness) -> SQLiteRunWorkspace: + manifest_path = harness.state().get("sqlite_manifest_path") + if not manifest_path: + raise ReportHarnessError("SQLite workspace has not been initialized") + return _workspace_from_manifest(harness.run_dir, manifest_path) + + +def _sqlite_table_preview(workspace: SQLiteRunWorkspace, table_name: str, *, limit: int = 25) -> dict[str, Any]: + quoted_table = _quote_sqlite_identifier(table_name) + with sqlite3.connect(workspace.sqlite_db_path) as conn: + conn.row_factory = sqlite3.Row + columns = [row[1] for row in conn.execute(f"PRAGMA table_info({quoted_table})")] + row_count = conn.execute(f"SELECT COUNT(*) FROM {quoted_table}").fetchone()[0] + rows = [ + dict(row) + for row in conn.execute( + f"SELECT * FROM {quoted_table} LIMIT ?", + (limit,), + ).fetchall() + ] + return { + "sqlite_db_path": str(workspace.sqlite_db_path), + "table_name": table_name, + "row_count": row_count, + "columns": columns, + "sample_rows": rows, + } + + +def _parse_excel_inputs(harness: ReportHarness, input_files: list[Path]) -> dict[str, Any] | None: + excel_files = [path for path in input_files if path.suffix.lower() == ".xlsx"] + if not excel_files: + return None + requirement = parse_excel_requirement(excel_files[0]) + payload = build_excel_confirmation_payload(requirement) + requirement_state = requirement.to_dict() + requirement_state.setdefault( + "database_fields", + [asdict(field) for field in requirement.database_fields], + ) + harness.update_state(excel_requirement=requirement_state) + return payload + + +def _build_prompt_sql( + prompt: str, + *, + source_dir: str | Path = "_Source", + llm_provider: str = "codex", + llm_model: str = "none", + llm_timeout_seconds: float = 30.0, + llm_min_confidence: float = 0.6, + llm_repair_attempts: int = 2, +) -> tuple[str, dict[str, Any]]: + bundle = load_schema_bundle(str(source_dir)) + return route_generate_sql( + prompt, + bundle, + RoutingOptions( + mode="llm-first", + llm_provider=llm_provider, + llm_model=llm_model, + llm_timeout_sec=llm_timeout_seconds, + min_confidence=llm_min_confidence, + llm_repair_attempts=llm_repair_attempts, + ), + ) + + +def _guard_execution(args: argparse.Namespace, harness: ReportHarness) -> bool: + if not args.validate_execution: + return False + if not args.confirm_sql: + print("SQL 尚未確認,未執行資料庫查詢。") + return False + db_env = str(args.db_env or os.getenv("DB_ENV", "")).strip().lower() + if db_env != "test" and not args.allow_non_test_db_execution: + print( + "ERROR: non-test DB execution requires --allow-non-test-db-execution", + file=sys.stderr, + ) + raise SystemExit(2) + state = harness.state() + if not state.get("sql_candidate") or "sql_review" not in { + item["checkpoint"] for item in state.get("checkpoints", []) + }: + print("ERROR: --confirm-sql requires an existing reviewed SQL checkpoint", file=sys.stderr) + raise SystemExit(2) + harness.confirm("sql_review", "同意查詢") + harness.update_state( + execution_result_summary={ + "status": "not_executed_by_harness", + "reason": "DB execution is delegated to the governed SQL validation path.", + } + ) + print("SQL 已確認;資料庫執行需由 governed validation path 產生 evidence。") + return True + + +def _create_run(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Create a WFERP report harness run.") + parser.add_argument("--run-root", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--prompt", required=True) + parser.add_argument("--input-file", action="append", type=Path, default=[]) + args = parser.parse_args(argv) + + try: + harness = ReportHarness.create( + args.run_root, + run_id=args.run_id, + prompt=args.prompt, + input_files=[str(path) for path in args.input_file], + ) + except FileExistsError as exc: + return _json_error("run_exists", str(exc)) + _write_stdout_json({"status": "created", "run_dir": str(harness.run_dir), "state": harness.state()}) + return 0 + + +def _write_excel_confirmation(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the Excel field/formula confirmation checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", default="") + parser.add_argument("--input-file", type=Path, default=None) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + if args.input_file: + payload = _parse_excel_inputs(harness, [args.input_file]) + if payload is None: + return _json_error("excel_required", f"Not an Excel file: {args.input_file}") + else: + payload = _load_json_arg_or_empty(args.payload) + checkpoint = harness.write_excel_confirmation(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("excel_confirmation_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _write_sql_review(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the SQL review checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--sql", default="") + parser.add_argument("--prompt", default="") + parser.add_argument("--validation", default="") + parser.add_argument("--source-dir", default="_Source") + parser.add_argument("--llm-provider", default=os.getenv("LLM_PROVIDER", "codex")) + parser.add_argument("--llm-model", default=os.getenv("LLM_MODEL", "none")) + parser.add_argument("--llm-timeout-seconds", type=float, default=30.0) + parser.add_argument("--llm-min-confidence", type=float, default=0.6) + parser.add_argument("--llm-repair-attempts", type=int, default=2) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + state = harness.state() + prompt = args.prompt or state.get("prompt", "") + route_meta: dict[str, Any] = {"route": "manual_sql", "reason": "USER_PROVIDED_SQL"} + if args.sql: + sql = args.sql + else: + sql, route_meta = _build_prompt_sql( + prompt, + source_dir=args.source_dir, + llm_provider=args.llm_provider, + llm_model=args.llm_model, + llm_timeout_seconds=args.llm_timeout_seconds, + llm_min_confidence=args.llm_min_confidence, + llm_repair_attempts=args.llm_repair_attempts, + ) + validation = _load_json_arg_or_empty(args.validation) or { + "status": "pending_user_confirmation", + "route": route_meta.get("route", ""), + "reason": route_meta.get("reason", ""), + "llm_provider": args.llm_provider if not args.sql else "", + "llm_model": args.llm_model if not args.sql else "", + } + if not args.sql and "candidate_sql" in route_meta: + validation["candidate_sql"] = route_meta["candidate_sql"] + _write_run_json(harness.run_dir, "sql/query.sql.json", {"sql": sql, "validation": validation}) + (harness.run_dir / "sql" / "query.sql").write_text(sql + "\n", encoding="utf-8") + checkpoint = harness.write_sql_review(sql, validation) + except (FileNotFoundError, ReportHarnessError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + return _json_error("sql_review_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _parse_selected_option_values(values: list[str]) -> dict[str, Any]: + selected: dict[str, Any] = {} + for value in values: + if "=" not in value: + raise ValueError(f"selected option must use key=value: {value}") + key, raw_value = value.split("=", 1) + lowered = raw_value.lower() + if lowered in {"true", "false"}: + selected[key] = lowered == "true" + else: + selected[key] = raw_value + return selected + + +def _parse_value_columns(values: list[str]) -> dict[str, str]: + columns: dict[str, str] = {} + for value in values: + if "=" not in value: + raise ValueError(f"value column must use output_name=ExcelColumn: {value}") + name, column = value.split("=", 1) + name = name.strip() + column = column.strip() + if not name or not column: + raise ValueError(f"value column must include both name and column: {value}") + columns[name] = column + if not columns: + raise ValueError("At least one --value-column is required") + return columns + + +def _confirm(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Confirm a checkpoint and persist selected options.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--action", required=True) + parser.add_argument("--comment", default="") + parser.add_argument("--selected-options", default="") + parser.add_argument("--selected-option", action="append", default=[]) + parser.add_argument("--accepted-residual-risk", action="append", default=[]) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + selected_options = _load_json_arg_or_empty(args.selected_options) + selected_options.update(_parse_selected_option_values(args.selected_option)) + if args.accepted_residual_risk: + selected_options["acceptedResidualRisks"] = list(args.accepted_residual_risk) + state = harness.confirm(args.checkpoint, args.action, selected_options=selected_options, comment=args.comment) + confirmation_path = _confirmation_path(harness.run_dir, args.checkpoint) + confirmation = _read_confirmation_payload(confirmation_path) + if args.comment: + confirmation["comment"] = args.comment + confirmation_path.write_text( + json.dumps(confirmation, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("confirmation_error", str(exc)) + _write_stdout_json( + { + "status": "confirmed", + "checkpoint": args.checkpoint, + "action": args.action, + "selectedOptions": selected_options, + "confirmation": confirmation, + "state": state, + } + ) + return 0 + + +def _write_data_preview(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the data preview checkpoint after SQL confirmation.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + payload = _load_json_arg(args.payload) + checkpoint = harness.write_data_preview(payload) + _write_run_json(harness.run_dir, "data/execution-result.json", payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("data_preview_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _classify_workbook(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Classify workbook fields for DB SQL and SQLite enrichment.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--input-file", required=True, type=Path) + parser.add_argument("--primary-sheet", default="") + parser.add_argument( + "--classification-payload", + default="", + help="JSON object or path produced by the authenticated current Codex session; skips external LLM CLI.", + ) + parser.add_argument("--source-dir", default="_Source") + parser.add_argument("--llm-provider", default=os.getenv("LLM_PROVIDER", "codex")) + parser.add_argument("--llm-model", default=os.getenv("LLM_MODEL", "default")) + parser.add_argument("--llm-timeout-seconds", type=float, default=60.0) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + if args.classification_payload: + payload = _load_json_arg(args.classification_payload) + else: + payload = classify_workbook_with_llm( + args.input_file, + source_dir=args.source_dir, + primary_sheet=args.primary_sheet, + user_prompt=str(harness.state().get("prompt") or ""), + llm_provider=args.llm_provider, + llm_model=args.llm_model, + timeout_sec=args.llm_timeout_seconds, + ) + _write_run_json(harness.run_dir, "data/column-classification.json", payload) + checkpoint = harness.write_field_formula_classification(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + return _json_error("classification_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _init_sqlite_workspace(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Initialize a run-scoped SQLite workspace.") + parser.add_argument("--run-dir", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + state = harness.state() + workspace = SQLiteRunWorkspace.create(harness.run_dir, run_id=str(state["run_id"])) + manifest = workspace.manifest() + harness.update_state(sqlite_manifest=manifest, sqlite_manifest_path=str(workspace.manifest_path)) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("sqlite_workspace_error", str(exc)) + _write_stdout_json( + { + "status": "initialized", + "sqlite_manifest_path": str(workspace.manifest_path), + "manifest": manifest, + } + ) + return 0 + + +def _import_lookups(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Import a workbook lookup sheet into the run SQLite workspace.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--input-file", required=True, type=Path) + parser.add_argument("--sheet-name", required=True) + parser.add_argument("--logical-name", required=True) + parser.add_argument("--key-column", required=True) + parser.add_argument("--value-column", action="append", default=[]) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + result = import_lookup_sheet( + args.input_file, + workspace, + sheet_name=args.sheet_name, + logical_name=args.logical_name, + key_column=args.key_column, + value_columns=_parse_value_columns(args.value_column), + ) + manifest = workspace.manifest() + harness.update_state(sqlite_manifest=manifest, sqlite_manifest_path=str(workspace.manifest_path)) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("lookup_import_error", str(exc)) + _write_stdout_json({**result, "manifest": manifest}) + return 0 + + +def _write_raw_table(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write formal DB raw rows into the SQLite raw table.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--rows", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + rows = _load_json_list_arg(args.rows) + workspace.write_raw_rows(rows) + manifest = workspace.manifest() + harness.update_state(sqlite_manifest=manifest, sqlite_manifest_path=str(workspace.manifest_path)) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("raw_table_error", str(exc)) + _write_stdout_json( + { + "status": "written", + "table_name": workspace.raw_table, + "row_count": manifest["raw_row_count"], + "manifest": manifest, + } + ) + return 0 + + +def _write_raw_preview(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write a checkpoint preview of the SQLite raw table.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--limit", type=int, default=25) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + payload = _sqlite_table_preview(workspace, workspace.raw_table, limit=args.limit) + checkpoint = harness.write_raw_data_preview(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, sqlite3.Error, json.JSONDecodeError) as exc: + return _json_error("raw_preview_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _run_sqlite_enrichment(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Run SQLite enrichment from JSON column payloads.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--computed-columns", default="[]") + parser.add_argument("--lookup-columns", default="[]") + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + result = run_enrichment( + workspace, + computed_columns=_load_json_list_arg(args.computed_columns), + lookup_columns=_load_json_list_arg(args.lookup_columns), + ) + manifest = workspace.manifest() + harness.update_state(sqlite_manifest=manifest, sqlite_manifest_path=str(workspace.manifest_path)) + except (FileNotFoundError, ReportHarnessError, ValueError, sqlite3.Error, json.JSONDecodeError) as exc: + return _json_error("sqlite_enrichment_error", str(exc)) + _write_stdout_json({"status": "enriched", "result": result, "manifest": manifest}) + return 0 + + +def _write_enriched_preview(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write a checkpoint preview of the enriched SQLite table.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--limit", type=int, default=25) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + payload = _sqlite_table_preview(workspace, workspace.enriched_table, limit=args.limit) + checkpoint = harness.write_enriched_data_preview(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, sqlite3.Error, json.JSONDecodeError) as exc: + return _json_error("enriched_preview_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _write_sqlite_retention(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write SQLite retention checkpoint payload.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--decision", default="keep", choices=["keep", "delete", "export_then_delete"]) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + manifest = workspace.manifest() + lookup_counts = manifest.get("lookup_row_counts", {}) + tables = [ + {"table_name": manifest["raw_table"], "row_count": manifest.get("raw_row_count", 0)}, + {"table_name": manifest["enriched_table"], "row_count": manifest.get("enriched_row_count", 0)}, + ] + if isinstance(lookup_counts, dict): + tables.extend( + {"table_name": str(table), "row_count": count} + for table, count in lookup_counts.items() + ) + payload = { + "manifest_path": str(workspace.manifest_path), + "sqlite_db_path": str(workspace.sqlite_db_path), + "tables": tables, + "default_action": "保留本地資料", + "retention_decision": args.decision, + "cleanup_status": manifest.get("cleanup_status", "active"), + } + checkpoint = harness.write_sqlite_retention(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("sqlite_retention_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _cleanup_sqlite_run(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Drop only SQLite tables listed in the run manifest.") + parser.add_argument("--run-dir", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + workspace = _workspace_from_state(harness) + workspace.cleanup_run_tables() + manifest = workspace.manifest() + harness.update_state(sqlite_manifest=manifest, sqlite_manifest_path=str(workspace.manifest_path)) + except (FileNotFoundError, ReportHarnessError, ValueError, sqlite3.Error, json.JSONDecodeError) as exc: + return _json_error("sqlite_cleanup_error", str(exc)) + _write_stdout_json({"status": "cleaned", "manifest": manifest}) + return 0 + + +def _write_report_selection(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the report selection checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", default="") + parser.add_argument("--report-type", default="") + parser.add_argument("--report-design", default="") + parser.add_argument("--include-chart", action="store_true") + parser.add_argument("--include-table", action="store_true") + parser.add_argument("--include-analysis", action="store_true") + parser.add_argument("--include-recommendations", action="store_true") + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + payload = _load_json_arg_or_empty(args.payload) or build_report_selection_payload() + if args.report_type: + payload["selected_report_type"] = args.report_type + if args.report_design: + get_report_design_defaults(args.report_design) + payload["selected_report_design"] = args.report_design + payload["selected_options"] = { + **payload.get("selected_options", {}), + "include_chart": args.include_chart, + "include_table": args.include_table, + "include_analysis": args.include_analysis, + "include_recommendations": args.include_recommendations, + } + checkpoint = harness.write_report_selection(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("report_selection_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _write_design_brief(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the dynamic design brief checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--package", required=True) + parser.add_argument("--overrides", default="") + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + package = _load_json_arg(args.package) + overrides = _load_json_arg_or_empty(args.overrides) + brief = build_design_brief(package, user_overrides=overrides) + result = validate_design_brief(brief) + if not result["valid"]: + return _json_error("design_brief_invalid", ", ".join(result["errors"])) + checkpoint = harness.write_design_brief(brief) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("design_brief_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _write_visual_checkpoint(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the semi-real visual design checkpoint.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--brief", required=True) + parser.add_argument("--package", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + brief = _payload_or_checkpoint_payload(_load_json_arg(args.brief)) + package = _payload_or_checkpoint_payload(_load_json_arg(args.package)) + payload = build_visual_checkpoint_payload(brief, package) + html_text = render_visual_checkpoint_html(payload) + checkpoint = harness.write_visual_design(payload) + _write_run_text(harness.run_dir, "visual/visual-checkpoint.html", html_text) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("visual_checkpoint_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _export_single_html(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Export a self-contained WFERP single HTML report.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--package", required=True) + parser.add_argument("--brief", required=True) + parser.add_argument("--output-root", default="") + args = parser.parse_args(argv) + + try: + package = _payload_or_checkpoint_payload(_load_json_arg(args.package)) + brief = _payload_or_checkpoint_payload(_load_json_arg(args.brief)) + output_root = Path(args.output_root) if args.output_root else Path(args.run_dir) + result = export_single_html_report(output_root, package, brief) + except (ValueError, json.JSONDecodeError, OSError) as exc: + return _json_error("single_html_export_error", str(exc)) + if result.get("status") == "error": + errors = result.get("errors", []) + message = ", ".join(str(error) for error in errors) + return _json_error("single_html_export_error", message) + _write_stdout_json(result) + return 0 + + +def _validate_single_html(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Validate exported single HTML report.") + parser.add_argument("--html", required=True) + args = parser.parse_args(argv) + + try: + result = validate_single_html_static(args.html) + except FileNotFoundError: + return _json_error("single_html_validation_error", f"HTML file not found: {args.html}") + except OSError as exc: + return _json_error("single_html_validation_error", str(exc)) + _write_stdout_json({"status": "validated", **result}) + return 0 if result["valid"] else 2 + + +def _inspect_style_replay(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Inspect whether a style capsule can replay against new columns.") + parser.add_argument("--capsule", required=True) + parser.add_argument("--columns", required=True) + args = parser.parse_args(argv) + + try: + capsule = _load_json_arg(args.capsule) + except (ValueError, json.JSONDecodeError, OSError) as exc: + return _json_error("style_replay_error", str(exc)) + columns = [part.strip() for part in args.columns.split(",") if part.strip()] + result = detect_replay_adjustments(capsule, new_columns=columns) + _write_stdout_json({"status": "checked", **result}) + return 2 if result["requires_checkpoint"] else 0 + + +def _write_report_draft(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the report draft checkpoint after report selection.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + payload = _load_json_arg(args.payload) + checkpoint = harness.write_report_draft(payload) + _write_run_json(harness.run_dir, "reports/report-draft.json", payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("report_draft_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _generate_report_section(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write LLM-generated TSX for one report section.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--section-id", required=True) + parser.add_argument("--component-name", required=True) + parser.add_argument("--code", default=None) + parser.add_argument("--code-file", default=None) + args = parser.parse_args(argv) + + try: + code = _load_text_arg(inline=args.code, file_path=args.code_file) + result = write_generated_report_section( + run_dir=Path(args.run_dir), + section_id=args.section_id, + component_name=args.component_name, + code=code, + mode="generate", + ) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + return _json_error("report_section_error", str(exc)) + _write_stdout_json(result) + return 0 + + +def _repair_report_section(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Repair one generated report section with replacement TSX.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--section-id", required=True) + parser.add_argument("--component-name", required=True) + parser.add_argument("--code", default=None) + parser.add_argument("--code-file", default=None) + args = parser.parse_args(argv) + + try: + code = _load_text_arg(inline=args.code, file_path=args.code_file) + result = write_generated_report_section( + run_dir=Path(args.run_dir), + section_id=args.section_id, + component_name=args.component_name, + code=code, + mode="repair", + ) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + return _json_error("report_section_error", str(exc)) + _write_stdout_json(result) + return 0 + + +def _validate_report_section(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Validate one generated report section and scaffold linkage.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--section-id", required=True) + parser.add_argument("--component-name", default=None) + args = parser.parse_args(argv) + + try: + result = validate_generated_report_section( + run_dir=Path(args.run_dir), + section_id=args.section_id, + component_name=args.component_name, + ) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + return _json_error("report_section_validation_error", str(exc)) + _write_stdout_json(result) + return 0 + + +def _write_final_review(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write the final review checkpoint with validator evidence.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + payload = _load_json_arg(args.payload) + _write_run_json(harness.run_dir, "review/final-review.json", payload) + checkpoint = harness.write_final_review(payload) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("final_review_error", str(exc)) + _write_stdout_json(checkpoint) + return 0 + + +def _can_deliver(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Evaluate final delivery gate.") + parser.add_argument("--run-dir", required=True) + args = parser.parse_args(argv) + + try: + harness = _open_harness(args.run_dir) + result = harness.can_deliver() + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError) as exc: + return _json_error("delivery_gate_error", str(exc)) + _write_stdout_json({"status": "ok", **result}) + return 0 if result.get("allowed") else 2 + + +def _check_delivery_artifacts(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Evaluate final HTML/manifest/validator artifacts.") + parser.add_argument("--run-dir", required=True) + parser.add_argument( + "--required-validator", + action="append", + default=[], + help="Validator role that must have JSON evidence with no required fixes. Repeatable.", + ) + args = parser.parse_args(argv) + + result = evaluate_delivery_artifacts( + Path(args.run_dir), + required_validators=[role for role in args.required_validator if role], + ) + _write_stdout_json({"status": "ok", **result}) + return 0 if result["allowed"] else 2 + + +def _check_run_text_artifacts(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Scan current run artifacts for UTF-8 readability issues.") + parser.add_argument("--run-dir", required=True) + args = parser.parse_args(argv) + + result = scan_run_text_artifacts(Path(args.run_dir)) + _write_stdout_json({"status": "ok", **result}) + return 0 if result["valid"] else 2 + + +def _write_user_step_preview(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Write a 4-step Visual Companion payload.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--step", required=True, type=int, choices=[1, 2, 3, 4]) + args = parser.parse_args(argv) + + try: + run_dir = Path(args.run_dir) + payload = build_user_step_payload(run_dir, args.step) + _write_run_json(run_dir, f"checkpoints/user_step_{args.step}.json", payload) + except (OSError, ValueError, json.JSONDecodeError) as exc: + return _json_error("user_step_preview_error", str(exc)) + _write_stdout_json(payload) + return 0 + + +def _export_excel_workbook(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Export a real XLSX workbook and record harness evidence.") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--payload", required=True) + parser.add_argument("--output", default="report/delivery/report.xlsx") + args = parser.parse_args(argv) + + try: + from skill_scripts.excel_workbook_exporter import export_workbook + + harness = _open_harness(args.run_dir) + payload = _payload_or_checkpoint_payload(_load_json_arg(args.payload)) + output_path = harness.run_dir / args.output + result = export_workbook( + payload, + output_path, + evidence_path=harness.run_dir / "review" / "excel-workbook-evidence.json", + ) + harness.update_state(final_xlsx_path=result["workbook_path"], excel_workbook_evidence=result) + except (FileNotFoundError, ReportHarnessError, ValueError, json.JSONDecodeError, OSError, RuntimeError) as exc: + return _json_error("excel_workbook_export_error", str(exc)) + _write_stdout_json(result) + return 0 + + +COMMANDS = { + "create-run": _create_run, + "write-excel-confirmation": _write_excel_confirmation, + "write-sql-review": _write_sql_review, + "classify-workbook": _classify_workbook, + "init-sqlite-workspace": _init_sqlite_workspace, + "import-lookups": _import_lookups, + "write-raw-table": _write_raw_table, + "write-raw-preview": _write_raw_preview, + "run-sqlite-enrichment": _run_sqlite_enrichment, + "write-enriched-preview": _write_enriched_preview, + "write-sqlite-retention": _write_sqlite_retention, + "cleanup-sqlite-run": _cleanup_sqlite_run, + "confirm": _confirm, + "write-data-preview": _write_data_preview, + "write-report-selection": _write_report_selection, + "write-design-brief": _write_design_brief, + "write-visual-checkpoint": _write_visual_checkpoint, + "export-single-html": _export_single_html, + "validate-single-html": _validate_single_html, + "inspect-style-replay": _inspect_style_replay, + "scaffold-report": _scaffold_report, + "generate-report-section": _generate_report_section, + "repair-report-section": _repair_report_section, + "validate-report-section": _validate_report_section, + "write-report-draft": _write_report_draft, + "write-final-review": _write_final_review, + "can-deliver": _can_deliver, + "check-delivery-artifacts": _check_delivery_artifacts, + "check-run-text-artifacts": _check_run_text_artifacts, + "write-user-step-preview": _write_user_step_preview, + "export-excel-workbook": _export_excel_workbook, + "serve-checkpoint": _serve_checkpoint, + "wait-confirmation": _wait_confirmation, +} + + +def _print_main_help() -> None: + command_lines = "\n".join(f" {name}" for name in sorted(COMMANDS)) + print( + "usage: python3 -m skill_scripts.cli_report_harness [options]\n" + " python3 -m skill_scripts.cli_report_harness --run-dir RUN_DIR [legacy options]\n\n" + "Create and advance WFERP report harness runs.\n\n" + "Primary subcommands:\n" + f"{command_lines}\n\n" + "Use ' --help' for command-specific options.\n\n" + "Legacy compatibility options are still accepted for older scripts, but the\n" + "wferp-report skill flow should use subcommands such as create-run,\n" + "classify-workbook, write-sql-review, serve-checkpoint, wait-confirmation,\n" + "write-raw-preview, write-enriched-preview, and export-single-html.\n" + ) + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + if argv in (["--help"], ["-h"]): + _print_main_help() + return 0 + if argv and argv[0] in COMMANDS: + return COMMANDS[argv[0]](argv[1:]) + + parser = argparse.ArgumentParser(description="Create and advance WFERP report harness runs.") + parser.add_argument("--prompt", default="") + parser.add_argument("--input-file", action="append", type=Path, default=[]) + parser.add_argument("--run-dir", required=True) + parser.add_argument("--source-dir", default="_Source") + parser.add_argument("--llm-provider", default=os.getenv("LLM_PROVIDER", "codex")) + parser.add_argument("--llm-model", default=os.getenv("LLM_MODEL", "none")) + parser.add_argument("--llm-timeout-seconds", type=float, default=30.0) + parser.add_argument("--llm-min-confidence", type=float, default=0.6) + parser.add_argument("--llm-repair-attempts", type=int, default=2) + parser.add_argument("--checkpoint", choices=["excel", "sql", "report-selection"], default="") + parser.add_argument("--confirm-sql", action="store_true") + parser.add_argument("--validate-execution", action="store_true") + parser.add_argument("--allow-non-test-db-execution", action="store_true") + parser.add_argument("--db-env", default=None) + parser.add_argument("--report-type", default="") + parser.add_argument("--report-design", default="") + parser.add_argument("--include-chart", action="store_true") + parser.add_argument("--include-table", action="store_true") + parser.add_argument("--include-analysis", action="store_true") + parser.add_argument("--include-recommendations", action="store_true") + args = parser.parse_args(argv) + + harness = _ensure_harness(args) + state = harness.state() + prompt = args.prompt or state.get("prompt", "") + + excel_payload = _parse_excel_inputs(harness, args.input_file) + if excel_payload is not None and args.checkpoint in {"", "excel"}: + harness.write_excel_confirmation(excel_payload) + + if args.checkpoint == "sql": + try: + sql, route_meta = _build_prompt_sql( + prompt, + source_dir=args.source_dir, + llm_provider=args.llm_provider, + llm_model=args.llm_model, + llm_timeout_seconds=args.llm_timeout_seconds, + llm_min_confidence=args.llm_min_confidence, + llm_repair_attempts=args.llm_repair_attempts, + ) + except (RuntimeError, ValueError) as exc: + _write_stderr_json({"status": "error", "code": "sql_review_error", "message": str(exc)}) + return 2 + harness.write_sql_review( + sql, + { + "status": "pending_user_confirmation", + "route": route_meta.get("route", ""), + "reason": route_meta.get("reason", ""), + "llm_provider": args.llm_provider, + "llm_model": args.llm_model, + }, + ) + + if args.checkpoint == "report-selection" or args.report_type or args.report_design: + payload = build_report_selection_payload() + if args.report_type: + payload["selected_report_type"] = args.report_type + if args.report_design: + try: + get_report_design_defaults(args.report_design) + except ValueError as exc: + _write_stderr_json( + { + "status": "error", + "code": "unknown_report_design", + "message": str(exc), + } + ) + return 2 + payload["selected_report_design"] = args.report_design + payload["selected_options"] = { + "include_chart": args.include_chart, + "include_table": args.include_table, + "include_analysis": args.include_analysis, + "include_recommendations": args.include_recommendations, + } + harness.write_report_selection(payload) + + _guard_execution(args, harness) + _write_stdout_json({"run_dir": str(harness.run_dir), "state": load_run_state(harness.run_dir)}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill_scripts/dashboard_canvas.py b/skill_scripts/dashboard_canvas.py new file mode 100644 index 0000000..40a080e --- /dev/null +++ b/skill_scripts/dashboard_canvas.py @@ -0,0 +1,289 @@ +from collections.abc import Mapping +from dataclasses import dataclass, replace +from typing import cast + +from skill_scripts.chart_spec import ChartSpec, author_chart_spec, render_chart_preview +from skill_scripts.governed_query import GovernedQueryResult, QueryEvidence + + +_APPROVED_DRAFT_MARKER = object() + + +@dataclass(frozen=True) +class DashboardChartDraft: + chart: ChartSpec + preview: dict[str, object] + approval_marker: object | None = None + + def to_payload(self) -> dict[str, object]: + return self.chart.to_payload() + + +@dataclass(frozen=True) +class DashboardCanvasItem: + chart_id: str + chart: DashboardChartDraft + x: int + y: int + w: int + h: int + + def to_payload(self) -> dict[str, object]: + return { + "chart_id": self.chart_id, + "x": self.x, + "y": self.y, + "w": self.w, + "h": self.h, + "chart": self.chart.to_payload(), + } + + +class DashboardCanvas: + def __init__(self, columns: int = 12, rows: int = 12) -> None: + self.columns: int = _int_value(columns) + self.rows: int = _int_value(rows) + if self.columns <= 0 or self.rows <= 0: + raise RuntimeError("DASHBOARD_CANVAS_INVALID_GRID") + self._items: dict[str, DashboardCanvasItem] = {} + + def add_chart(self, chart_id: str, chart: object, x: int, y: int, w: int, h: int) -> DashboardCanvasItem: + normalized_id = _chart_id(chart_id) + if normalized_id in self._items: + raise RuntimeError(f"DASHBOARD_CANVAS_DUPLICATE_CHART:{normalized_id}") + if not isinstance(chart, DashboardChartDraft) or chart.approval_marker is not _APPROVED_DRAFT_MARKER: + raise RuntimeError("INVALID_CHART_DRAFT") + item = DashboardCanvasItem(chart_id=normalized_id, chart=chart, x=_int_value(x), y=_int_value(y), w=_int_value(w), h=_int_value(h)) + self._validate_item(item) + self._items[normalized_id] = item + return item + + def move_chart(self, chart_id: str, x: int, y: int) -> DashboardCanvasItem: + item = self._existing_item(chart_id) + moved = replace(item, x=_int_value(x), y=_int_value(y)) + self._validate_item(moved, ignore_chart_id=item.chart_id) + self._items[item.chart_id] = moved + return moved + + def resize_chart(self, chart_id: str, w: int, h: int) -> DashboardCanvasItem: + item = self._existing_item(chart_id) + resized = replace(item, w=_int_value(w), h=_int_value(h)) + self._validate_item(resized, ignore_chart_id=item.chart_id) + self._items[item.chart_id] = resized + return resized + + def render_layout(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self._ordered_items()] + + def to_payload(self) -> dict[str, object]: + return {"columns": self.columns, "rows": self.rows, "items": self.render_layout()} + + def _ordered_items(self) -> list[DashboardCanvasItem]: + return sorted(self._items.values(), key=lambda item: (item.y, item.x, item.chart_id)) + + def _existing_item(self, chart_id: str) -> DashboardCanvasItem: + normalized_id = _chart_id(chart_id) + try: + return self._items[normalized_id] + except KeyError as exc: + raise RuntimeError(f"DASHBOARD_CANVAS_CHART_NOT_FOUND:{normalized_id}") from exc + + def _validate_item(self, item: DashboardCanvasItem, ignore_chart_id: str = "") -> None: + if item.x < 0 or item.y < 0 or item.w <= 0 or item.h <= 0 or item.x + item.w > self.columns or item.y + item.h > self.rows: + raise RuntimeError(f"DASHBOARD_CANVAS_OUT_OF_BOUNDS:{item.chart_id}") + for existing in self._ordered_items(): + if existing.chart_id == ignore_chart_id: + continue + if _overlaps(item, existing): + raise RuntimeError(f"DASHBOARD_CANVAS_OVERLAP:{item.chart_id}:{existing.chart_id}") + + +def load_dashboard_canvas(payload: Mapping[str, object]) -> DashboardCanvas: + canvas = DashboardCanvas(columns=_int_value(payload.get("columns", 12)), rows=_int_value(payload.get("rows", 12))) + items = payload.get("items", []) + if not isinstance(items, list): + raise RuntimeError("DASHBOARD_CANVAS_INVALID_PAYLOAD") + item_values = cast(list[object], items) + for item_value in item_values: + if not isinstance(item_value, dict): + raise RuntimeError("DASHBOARD_CANVAS_INVALID_PAYLOAD") + item = cast(dict[str, object], item_value) + chart_payload = item.get("chart") + if not isinstance(chart_payload, dict): + raise RuntimeError("INVALID_CHART_DRAFT") + _ = canvas.add_chart( + chart_id=str(item.get("chart_id", "")), + chart=_persisted_chart_draft_from_payload(cast(dict[str, object], chart_payload)), + x=_int_value(item.get("x", 0)), + y=_int_value(item.get("y", 0)), + w=_int_value(item.get("w", 0)), + h=_int_value(item.get("h", 0)), + ) + return canvas + + +def create_dashboard_chart_draft(result: GovernedQueryResult, chart_payload: Mapping[str, object]) -> DashboardChartDraft: + chart = author_chart_spec(result, chart_payload) + preview = render_chart_preview(result, chart) + return DashboardChartDraft(chart=chart, preview=preview, approval_marker=_APPROVED_DRAFT_MARKER) + + +def _chart_id(value: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise RuntimeError("DASHBOARD_CANVAS_CHART_ID_REQUIRED") + return normalized + + +def _int_value(value: object) -> int: + if isinstance(value, bool): + raise RuntimeError("DASHBOARD_CANVAS_INVALID_PAYLOAD") + if isinstance(value, int): + return value + if isinstance(value, str): + stripped = value.strip() + if stripped and (stripped.isdigit() or (stripped.startswith("-") and stripped[1:].isdigit())): + return int(stripped) + raise RuntimeError("DASHBOARD_CANVAS_INVALID_PAYLOAD") + + +def _persisted_chart_draft_from_payload(payload: Mapping[str, object]) -> DashboardChartDraft: + chart = _validate_persisted_chart_payload(payload) + return DashboardChartDraft(chart=chart, preview={}, approval_marker=_APPROVED_DRAFT_MARKER) + + +def _validate_persisted_chart_payload(payload: Mapping[str, object]) -> ChartSpec: + chart_type = str(payload.get("chart_type", "")).strip() + title = str(payload.get("title", "")).strip() + _require_clean_string(payload.get("chart_type", "")) + _require_clean_string(payload.get("title", "")) + if not title: + raise RuntimeError("INVALID_CHART_DRAFT") + if chart_type not in {"table", "kpi_card", "bar", "line"}: + raise RuntimeError("INVALID_CHART_DRAFT") + if chart_type == "table" and not _string_list(payload.get("fields", [])): + raise RuntimeError("INVALID_CHART_DRAFT") + if chart_type == "table": + _require_clean_string_list(payload.get("fields", [])) + if chart_type == "kpi_card" and not str(payload.get("value_field", "")).strip(): + raise RuntimeError("INVALID_CHART_DRAFT") + if chart_type == "kpi_card": + _require_clean_string(payload.get("value_field", "")) + label_field = str(payload.get("label_field", "")) + if label_field: + _require_clean_string(label_field) + if chart_type in {"bar", "line"} and (not str(payload.get("x_field", "")).strip() or not str(payload.get("y_field", "")).strip()): + raise RuntimeError("INVALID_CHART_DRAFT") + if chart_type in {"bar", "line"}: + _require_clean_string(payload.get("x_field", "")) + _require_clean_string(payload.get("y_field", "")) + series_field = str(payload.get("series_field", "")) + if series_field: + _require_clean_string(series_field) + _validate_persisted_optional_field_mapping(payload.get("sort", {}), allowed_keys={"field", "direction"}) + _validate_persisted_optional_field_mapping(payload.get("number_format", {}), allowed_keys={"field", "style", "currency"}) + _validate_persisted_optional_field_mapping(payload.get("date_format", {}), allowed_keys={"field", "format"}) + color = str(payload.get("color", "")).strip() + if color: + _require_clean_string(payload.get("color", "")) + if color and (len(color) != 7 or not color.startswith("#")): + raise RuntimeError("INVALID_CHART_DRAFT") + if color: + try: + _ = int(color[1:], 16) + except ValueError as exc: + raise RuntimeError("INVALID_CHART_DRAFT") from exc + try: + return author_chart_spec(_persisted_chart_result(payload), payload) + except RuntimeError as exc: + raise RuntimeError("INVALID_CHART_DRAFT") from exc + + +def _persisted_chart_result(payload: Mapping[str, object]) -> GovernedQueryResult: + return GovernedQueryResult( + evidence=QueryEvidence( + prompt="persisted Dashboard Canvas chart replay", + sql="SELECT [persisted]", + route="persisted", + route_reason="dashboard_canvas_reload", + validation_status="OK", + execution_status="OK", + execution_timestamp="", + returned_columns=_persisted_chart_fields(payload), + row_count=1, + sample_rows=[_persisted_chart_sample_row(payload)], + ), + approval_state="approved_for_chart_spec", + ) + + +def _persisted_chart_fields(payload: Mapping[str, object]) -> list[str]: + fields: list[str] = [] + for key in ("fields", "x_field", "y_field", "series_field", "value_field", "label_field"): + value = payload.get(key, []) if key == "fields" else payload.get(key, "") + if isinstance(value, list): + list_values = cast(list[object], value) + for item in list_values: + if isinstance(item, str) and item.strip() and item.strip() not in fields: + fields.append(item.strip()) + if isinstance(value, str) and value.strip() and value.strip() not in fields: + fields.append(value.strip()) + for value in (payload.get("sort", {}), payload.get("number_format", {}), payload.get("date_format", {})): + if isinstance(value, dict): + mapping = cast(dict[object, object], value) + field_name = mapping.get("field", "") + if isinstance(field_name, str) and field_name.strip() and field_name.strip() not in fields: + fields.append(field_name.strip()) + return fields + + +def _persisted_chart_sample_row(payload: Mapping[str, object]) -> dict[str, object]: + row: dict[str, object] = {} + number_field = "" + number_format = payload.get("number_format", {}) + if isinstance(number_format, dict): + number_mapping = cast(dict[object, object], number_format) + field_value = number_mapping.get("field", "") + if isinstance(field_value, str): + number_field = field_value.strip() + for field_name in _persisted_chart_fields(payload): + row[field_name] = 1.0 if field_name == number_field else field_name + return row + + +def _validate_persisted_optional_field_mapping(value: object, allowed_keys: set[str]) -> None: + if value in ({}, None): + return + if not isinstance(value, dict): + raise RuntimeError("INVALID_CHART_DRAFT") + mapping = cast(dict[object, object], value) + if any(not isinstance(key, str) or key not in allowed_keys for key in mapping): + raise RuntimeError("INVALID_CHART_DRAFT") + if any(not isinstance(mapped, str) or not mapped.strip() for mapped in mapping.values()): + raise RuntimeError("INVALID_CHART_DRAFT") + if any(mapped != mapped.strip() for mapped in mapping.values() if isinstance(mapped, str)): + raise RuntimeError("INVALID_CHART_DRAFT") + + +def _require_clean_string(value: object) -> None: + if not isinstance(value, str) or value != value.strip(): + raise RuntimeError("INVALID_CHART_DRAFT") + + +def _require_clean_string_list(value: object) -> None: + if not isinstance(value, list): + raise RuntimeError("INVALID_CHART_DRAFT") + values = cast(list[object], value) + if any(not isinstance(item, str) or not item.strip() or item != item.strip() for item in values): + raise RuntimeError("INVALID_CHART_DRAFT") + + +def _string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + values = cast(list[object], value) + return [item for item in values if isinstance(item, str)] + + +def _overlaps(left: DashboardCanvasItem, right: DashboardCanvasItem) -> bool: + return left.x < right.x + right.w and left.x + left.w > right.x and left.y < right.y + right.h and left.y + left.h > right.y diff --git a/skill_scripts/dashboard_publish.py b/skill_scripts/dashboard_publish.py new file mode 100644 index 0000000..a11e618 --- /dev/null +++ b/skill_scripts/dashboard_publish.py @@ -0,0 +1,329 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +import secrets +from typing import Protocol, cast + +from skill_scripts.dashboard_canvas import load_dashboard_canvas +from skill_scripts.design_template_catalog import DesignTemplateSelection, apply_design_template_to_canvas_preview + + +AuditEvent = dict[str, object] +MIN_REFRESH_INTERVAL_SECONDS = 60 +MAX_REFRESH_INTERVAL_SECONDS = 3600 + + +@dataclass(frozen=True) +class DashboardDraft: + dashboard_id: str + canvas_payload: Mapping[str, object] + template_selection: DesignTemplateSelection + state: str = "draft" + audit_events: list[AuditEvent] = field(default_factory=list) + + +@dataclass(frozen=True) +class PublishedDashboard: + dashboard_id: str + version: int + payload: dict[str, object] + audit_events: list[AuditEvent] = field(default_factory=list) + + +@dataclass(frozen=True) +class PublishedDashboardLink: + token: str + dashboard_id: str + version: int + expires_at: str | None = None + revoked: bool = False + audit_events: list[AuditEvent] = field(default_factory=list) + + +class ReadOnlyDashboardDataSource(Protocol): + def read_published_dashboard(self, published: PublishedDashboard, *, reason: str) -> Mapping[str, object]: ... + + +def preview_dashboard_draft(draft: DashboardDraft) -> dict[str, object]: + dashboard_id = _dashboard_id(draft.dashboard_id) + canvas = load_dashboard_canvas(draft.canvas_payload) + preview = apply_design_template_to_canvas_preview(canvas, draft.template_selection) + return { + "dashboard_id": dashboard_id, + "state": draft.state, + "template": preview["template"], + "canvas": preview["canvas"], + } + + +def approve_dashboard_draft(draft: DashboardDraft, approver: str) -> DashboardDraft: + if draft.state not in {"draft", "reapproval_required"}: + raise RuntimeError(f"DASHBOARD_DRAFT_NOT_APPROVABLE:{draft.state}") + approver_id = _required_text(approver, "DASHBOARD_APPROVER_REQUIRED") + event: AuditEvent = { + "event": "dashboard_final_approved", + "dashboard_id": _dashboard_id(draft.dashboard_id), + "state": "approved", + "approver": approver_id, + } + return DashboardDraft( + dashboard_id=draft.dashboard_id, + canvas_payload=draft.canvas_payload, + template_selection=draft.template_selection, + state="approved", + audit_events=[*_audit_events(draft.audit_events), event], + ) + + +def reject_dashboard_draft(draft: DashboardDraft, approver: str, reason: str) -> DashboardDraft: + if draft.state not in {"draft", "reapproval_required"}: + raise RuntimeError(f"DASHBOARD_DRAFT_NOT_REJECTABLE:{draft.state}") + approver_id = _required_text(approver, "DASHBOARD_APPROVER_REQUIRED") + rejection_reason = _required_text(reason, "DASHBOARD_REJECTION_REASON_REQUIRED") + event: AuditEvent = { + "event": "dashboard_final_rejected", + "dashboard_id": _dashboard_id(draft.dashboard_id), + "state": "rejected", + "approver": approver_id, + "reason": rejection_reason, + } + return DashboardDraft( + dashboard_id=draft.dashboard_id, + canvas_payload=draft.canvas_payload, + template_selection=draft.template_selection, + state="rejected", + audit_events=[*_audit_events(draft.audit_events), event], + ) + + +def mark_dashboard_data_meaning_changed(draft: DashboardDraft, reason: str) -> DashboardDraft: + change_reason = _required_data_meaning_reason(reason) + event: AuditEvent = { + "event": "dashboard_reapproval_required", + "dashboard_id": _dashboard_id(draft.dashboard_id), + "state": "reapproval_required", + "reason": change_reason, + } + return DashboardDraft( + dashboard_id=draft.dashboard_id, + canvas_payload=draft.canvas_payload, + template_selection=draft.template_selection, + state="reapproval_required", + audit_events=[*_audit_events(draft.audit_events), event], + ) + + +def publish_dashboard(draft: DashboardDraft, previous: PublishedDashboard | None = None) -> PublishedDashboard: + if draft.state != "approved": + if draft.state == "reapproval_required": + raise RuntimeError("DASHBOARD_REAPPROVAL_REQUIRED") + raise RuntimeError("DASHBOARD_FINAL_APPROVAL_REQUIRED") + dashboard_id = _dashboard_id(draft.dashboard_id) + version = 1 if previous is None else previous.version + 1 + preview = preview_dashboard_draft(draft) + payload: dict[str, object] = { + "dashboard_id": dashboard_id, + "version": version, + "state": "published", + "template": _copy_mapping(cast(Mapping[str, object], preview["template"])), + "canvas": _copy_mapping(cast(Mapping[str, object], preview["canvas"])), + } + event: AuditEvent = { + "event": "dashboard_published", + "dashboard_id": dashboard_id, + "version": version, + "state": "published", + } + return PublishedDashboard( + dashboard_id=dashboard_id, + version=version, + payload=payload, + audit_events=[*_audit_events(draft.audit_events), event], + ) + + +def create_published_dashboard_link(published: PublishedDashboard, expires_at: str | None = None) -> PublishedDashboardLink: + event: AuditEvent = { + "event": "published_dashboard_link_created", + "dashboard_id": published.dashboard_id, + "version": published.version, + "expires_at": expires_at, + } + return PublishedDashboardLink( + token=secrets.token_urlsafe(32), + dashboard_id=published.dashboard_id, + version=published.version, + expires_at=expires_at, + audit_events=[*_audit_events(published.audit_events), event], + ) + + +def revoke_published_dashboard_link(link: PublishedDashboardLink) -> PublishedDashboardLink: + event: AuditEvent = { + "event": "published_dashboard_link_revoked", + "dashboard_id": link.dashboard_id, + "version": link.version, + } + return PublishedDashboardLink( + token=link.token, + dashboard_id=link.dashboard_id, + version=link.version, + expires_at=link.expires_at, + revoked=True, + audit_events=[*_audit_events(link.audit_events), event], + ) + + +def render_published_dashboard_link( + *, + token: str, + links: list[PublishedDashboardLink], + dashboards: list[PublishedDashboard], + data_source: ReadOnlyDashboardDataSource, + now: str, +) -> dict[str, object]: + link = _find_link(token, links) + if link is None: + return _denied("published_dashboard_link_malformed", "LINK_TOKEN_MALFORMED", token="") + if link.revoked: + return _denied("published_dashboard_link_denied_revoked", "LINK_TOKEN_REVOKED", link=link) + if link.expires_at is not None: + try: + expired = _parse_timestamp(now) >= _parse_timestamp(link.expires_at) + except ValueError: + return _denied("published_dashboard_link_denied_malformed_expiration", "LINK_EXPIRATION_MALFORMED", link=link) + if expired: + return _denied("published_dashboard_link_denied_expired", "LINK_TOKEN_EXPIRED", link=link) + published = _find_published_dashboard(link, dashboards) + if published is None: + return _denied("published_dashboard_link_denied_unpublished", "PUBLISHED_DASHBOARD_NOT_AVAILABLE", link=link) + try: + data = data_source.read_published_dashboard(published, reason="view") + except Exception: + return _view_failed(link) + return { + "status": "ok", + "view_only": True, + "authoring_actions": [], + "dashboard": _copy_mapping(published.payload), + "data": data, + "audit_events": _audit_events(link.audit_events), + } + + +def refresh_published_dashboard_view( + rendered_view: Mapping[str, object], + *, + published: PublishedDashboard, + data_source: ReadOnlyDashboardDataSource, + refresh_interval_seconds: int, +) -> dict[str, object]: + interval = _refresh_interval(refresh_interval_seconds) + refreshed = _copy_mapping(rendered_view) + try: + refreshed["data"] = data_source.read_published_dashboard(published, reason="refresh") + refreshed["refresh"] = {"status": "fresh", "interval_seconds": interval} + except Exception: + event: AuditEvent = { + "event": "published_dashboard_refresh_failed", + "dashboard_id": published.dashboard_id, + "version": published.version, + "code": "DASHBOARD_REFRESH_FAILED", + } + refreshed["refresh"] = {"status": "stale_error", "interval_seconds": interval, "code": "DASHBOARD_REFRESH_FAILED"} + refreshed["audit_events"] = [*_audit_events(cast(list[AuditEvent], rendered_view.get("audit_events", []))), event] + return refreshed + + +def _dashboard_id(value: str) -> str: + return _required_text(value, "DASHBOARD_ID_REQUIRED") + + +def _find_link(token: str, links: list[PublishedDashboardLink]) -> PublishedDashboardLink | None: + text = str(token or "").strip() + if not text: + return None + for link in links: + if secrets.compare_digest(link.token, text): + return link + return None + + +def _find_published_dashboard(link: PublishedDashboardLink, dashboards: list[PublishedDashboard]) -> PublishedDashboard | None: + for dashboard in dashboards: + if dashboard.dashboard_id == link.dashboard_id and dashboard.version == link.version and dashboard.payload.get("state") == "published": + return dashboard + return None + + +def _denied(event_name: str, code: str, *, link: PublishedDashboardLink | None = None, token: str | None = None) -> dict[str, object]: + event: AuditEvent = {"event": event_name, "code": code} + if link is not None: + event["dashboard_id"] = link.dashboard_id + event["version"] = link.version + if token is not None: + event["token_present"] = bool(token) + return {"status": "denied", "code": code, "dashboard": None, "data": None, "audit_events": [event]} + + +def _view_failed(link: PublishedDashboardLink) -> dict[str, object]: + event: AuditEvent = { + "event": "published_dashboard_view_failed", + "dashboard_id": link.dashboard_id, + "version": link.version, + "code": "DASHBOARD_VIEW_FAILED", + } + return { + "status": "error", + "code": "DASHBOARD_VIEW_FAILED", + "view_only": True, + "authoring_actions": [], + "dashboard": None, + "data": None, + "audit_events": [*_audit_events(link.audit_events), event], + } + + +def _parse_timestamp(value: str) -> datetime: + text = _required_text(value, "TIMESTAMP_REQUIRED") + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _refresh_interval(value: int) -> int: + interval = int(value) + if interval < MIN_REFRESH_INTERVAL_SECONDS: + raise RuntimeError("DASHBOARD_REFRESH_INTERVAL_TOO_FAST") + if interval > MAX_REFRESH_INTERVAL_SECONDS: + raise RuntimeError("DASHBOARD_REFRESH_INTERVAL_TOO_SLOW") + return interval + + +def _required_text(value: str, code: str) -> str: + text = str(value or "").strip() + if not text: + raise RuntimeError(code) + return text + + +def _required_data_meaning_reason(value: str) -> str: + reason = _required_text(value, "DASHBOARD_DATA_MEANING_CHANGE_REASON_REQUIRED") + if reason not in {"sql_changed", "dashboard_data_source_changed", "query_semantics_changed"}: + raise RuntimeError(f"DASHBOARD_DATA_MEANING_CHANGE_NOT_SUPPORTED:{reason}") + return reason + + +def _audit_events(events: list[AuditEvent]) -> list[AuditEvent]: + return [_copy_mapping(event) for event in events] + + +def _copy_mapping(value: Mapping[str, object]) -> dict[str, object]: + copied: dict[str, object] = {} + for key, item in value.items(): + if isinstance(item, dict): + copied[key] = _copy_mapping(cast(Mapping[str, object], item)) + elif isinstance(item, list): + values = cast(list[object], item) + copied[key] = [_copy_mapping(cast(Mapping[str, object], entry)) if isinstance(entry, dict) else entry for entry in values] + else: + copied[key] = item + return copied diff --git a/skill_scripts/data_dictionary.py b/skill_scripts/data_dictionary.py new file mode 100644 index 0000000..713c6e4 --- /dev/null +++ b/skill_scripts/data_dictionary.py @@ -0,0 +1,34 @@ +from collections import defaultdict +from typing import Any + + +JsonDict = dict[str, Any] + + +def _table_field_ref(row: JsonDict) -> str: + table = str(row.get("TableID", "")).strip() + field = str(row.get("ID", "")).strip() + return f"{table}.{field}" + + +def build_field_index(fields: list[JsonDict]) -> dict[str, list[str]]: + out = defaultdict(list) + for row in fields: + field_id = str(row.get("ID", "")).strip() + if not field_id: + continue + out[field_id].append(_table_field_ref(row)) + return {k: v for k, v in out.items()} + + +def build_alias_index(fields: list[JsonDict]) -> dict[str, list[str]]: + out = defaultdict(list) + for row in fields: + ref = _table_field_ref(row) + for key in ("ID", "FieldName", "NameVietnam"): + alias = str(row.get(key, "")).strip().lower() + if not alias: + continue + if ref not in out[alias]: + out[alias].append(ref) + return {k: v for k, v in out.items()} diff --git a/skill_scripts/database_client.py b/skill_scripts/database_client.py new file mode 100644 index 0000000..65fcd67 --- /dev/null +++ b/skill_scripts/database_client.py @@ -0,0 +1,244 @@ +from dataclasses import dataclass, replace +import importlib +import os +from typing import Protocol, cast + +from skill_scripts.sql2000_guard import validate_sql + + +class _CursorProtocol(Protocol): + description: object | None + + def execute(self, sql: str) -> object: ... + + def fetchall(self) -> list[tuple[object, ...]]: ... + + +class _ConnectionProtocol(Protocol): + def cursor(self) -> _CursorProtocol: ... + + def close(self) -> object: ... + + +class _PymssqlModuleProtocol(Protocol): + def connect( + self, + *, + server: str, + user: str, + password: str, + database: str, + port: int | None = None, + ) -> _ConnectionProtocol: ... + + +class _PyodbcModuleProtocol(Protocol): + def connect(self, connection_string: str) -> _ConnectionProtocol: ... + + +def _load_pymssql_module() -> _PymssqlModuleProtocol: + return cast(_PymssqlModuleProtocol, cast(object, importlib.import_module("pymssql"))) + + +def _load_pyodbc_module() -> _PyodbcModuleProtocol: + return cast(_PyodbcModuleProtocol, cast(object, importlib.import_module("pyodbc"))) + + +@dataclass(frozen=True) +class DatabaseConfig: + driver: str + connection_string: str + auth_mode: str + env: str + host: str + port: int + database: str + username: str + password: str + domain: str + odbc_driver: str + + @staticmethod + def from_env() -> "DatabaseConfig": + return DatabaseConfig( + driver=os.getenv("DB_DRIVER", "mssql").strip().lower(), + connection_string=os.getenv("DB_CONNECTION_STRING", "").strip(), + auth_mode=os.getenv("DB_AUTH_MODE", "sql_auth").strip().lower(), + env=os.getenv("DB_ENV", "test").strip().lower(), + host=os.getenv("DB_HOST", "127.0.0.1").strip(), + port=int(os.getenv("DB_PORT", "1433").strip()), + database=os.getenv("DB_DATABASE", "wferp_test").strip(), + username=os.getenv("DB_USERNAME", "sa").strip(), + password=os.getenv("DB_PASSWORD", "").strip(), + domain=os.getenv("DB_DOMAIN", "").strip(), + odbc_driver=os.getenv("DB_ODBC_DRIVER", "ODBC Driver 18 for SQL Server").strip(), + ) + + def with_overrides( + self, + *, + driver: str | None = None, + connection_string: str | None = None, + auth_mode: str | None = None, + env: str | None = None, + ) -> "DatabaseConfig": + updated = self + if driver is not None: + updated = replace(updated, driver=driver.strip().lower()) + if connection_string is not None: + updated = replace(updated, connection_string=connection_string.strip()) + if auth_mode is not None: + updated = replace(updated, auth_mode=auth_mode.strip().lower()) + if env is not None: + updated = replace(updated, env=env.strip().lower()) + return updated + + def resolved_connection_string(self) -> str: + if self.connection_string: + return self.connection_string + + if self.driver == "mssql": + user = self.username + if self.auth_mode == "windows_domain" and self.domain: + user = f"{self.domain}\\{self.username}" + return ( + f"server={self.host}:{self.port};" + f"user={user};" + f"password={self.password};" + f"database={self.database}" + ) + + if self.driver == "pyodbc": + if self.auth_mode == "windows_domain": + return ( + f"DRIVER={{{self.odbc_driver}}};" + f"SERVER={self.host},{self.port};" + f"DATABASE={self.database};" + "Trusted_Connection=yes;" + ) + return ( + f"DRIVER={{{self.odbc_driver}}};" + f"SERVER={self.host},{self.port};" + f"DATABASE={self.database};" + f"UID={self.username};PWD={self.password};" + "Encrypt=no;" + ) + + raise RuntimeError("DB_DRIVER_UNSUPPORTED") + + +class DatabaseClient: + config: DatabaseConfig + + def __init__(self, config: DatabaseConfig): + self.config = config + + @staticmethod + def _parse_kv_connection_string(conn_str: str) -> dict[str, str]: + parts = [segment.strip() for segment in str(conn_str or "").split(";") if segment.strip()] + parsed: dict[str, str] = {} + for part in parts: + if "=" not in part: + continue + key, value = part.split("=", 1) + parsed[key.strip().lower()] = value.strip() + return parsed + + @staticmethod + def _split_server_and_port(server: str) -> tuple[str, int | None]: + text = str(server or "").strip() + if not text: + return "", None + if ":" in text: + host, port_text = text.rsplit(":", 1) + try: + return host.strip(), int(port_text.strip()) + except ValueError: + return text, None + return text, None + + def health_check(self) -> tuple[bool, str]: + conn_str = self.config.resolved_connection_string().strip() + if not conn_str: + return False, "DB_CONNECTION_NOT_CONFIGURED" + + parsed = self._parse_kv_connection_string(conn_str) + password = parsed.get("password", parsed.get("pwd", self.config.password)) + username = parsed.get("user", parsed.get("uid", self.config.username)) + + if self.config.driver == "mssql" and self.config.auth_mode == "sql_auth" and not password: + return False, "DB_PASSWORD_MISSING" + + if self.config.auth_mode == "windows_domain" and not username: + return False, "DB_USERNAME_MISSING" + + return True, "OK" + + def _connect(self) -> _ConnectionProtocol: + conn_str = self.config.resolved_connection_string() + + if self.config.driver == "mssql": + try: + pymssql = _load_pymssql_module() + except ImportError as exc: + raise RuntimeError("DB_DRIVER_NOT_INSTALLED") from exc + try: + cfg = self._parse_kv_connection_string(conn_str) + server = cfg.get("server", self.config.host) + user = cfg.get("user", cfg.get("uid", self.config.username)) + password = cfg.get("password", cfg.get("pwd", self.config.password)) + database = cfg.get("database", cfg.get("initial catalog", self.config.database)) + + host, parsed_port = self._split_server_and_port(server) + server_host = host or self.config.host + if parsed_port is None: + return pymssql.connect( + server=server_host, + user=user, + password=password, + database=database, + ) + return pymssql.connect( + server=server_host, + user=user, + password=password, + database=database, + port=parsed_port, + ) + except Exception as exc: + raise RuntimeError("DB_CONNECTION_FAILED") from exc + + if self.config.driver == "pyodbc": + try: + pyodbc = _load_pyodbc_module() + except ImportError as exc: + raise RuntimeError("DB_DRIVER_NOT_INSTALLED") from exc + try: + return pyodbc.connect(conn_str) + except Exception as exc: + raise RuntimeError("DB_CONNECTION_FAILED") from exc + + raise RuntimeError("DB_DRIVER_UNSUPPORTED") + + def execute_readonly(self, sql: str) -> list[dict[str, object]]: + ok, code = validate_sql(sql) + if not ok: + raise RuntimeError(code) + + conn = self._connect() + try: + cur = conn.cursor() + _ = cur.execute(sql) + description = cast(list[tuple[object, ...]] | None, cur.description) + columns = [str(col[0]) for col in description] if description else [] + rows = cur.fetchall() + return [{columns[i]: row[i] for i in range(len(columns))} for row in rows] + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError("DB_EXECUTION_FAILED") from exc + finally: + try: + _ = conn.close() + except Exception: + pass diff --git a/skill_scripts/design_template_catalog.py b/skill_scripts/design_template_catalog.py new file mode 100644 index 0000000..2591bf3 --- /dev/null +++ b/skill_scripts/design_template_catalog.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass + +from skill_scripts.dashboard_canvas import DashboardCanvas + + +@dataclass(frozen=True) +class DesignTemplate: + template_id: str + name: str + description: str + tokens: dict[str, str] + + def to_option_payload(self) -> dict[str, object]: + return { + "template_id": self.template_id, + "name": self.name, + "description": self.description, + "preview_tokens": { + "background": self.tokens["background"], + "surface": self.tokens["surface"], + "accent": self.tokens["accent"], + }, + } + + +@dataclass(frozen=True) +class DesignTemplateSelection: + template_id: str + name: str + tokens: dict[str, str] + + def to_payload(self) -> dict[str, object]: + return {"template_id": self.template_id, "name": self.name, "tokens": dict(self.tokens)} + + +_TEMPLATES = ( + DesignTemplate( + template_id="executive_contrast", + name="Executive Contrast", + description="High-contrast dashboard styling for dense operational reviews.", + tokens={"background": "#0f172a", "surface": "#1e293b", "accent": "#38bdf8", "text": "#f8fafc"}, + ), + DesignTemplate( + template_id="soft_operations", + name="Soft Operations", + description="Light, spacious dashboard styling for day-to-day process monitoring.", + tokens={"background": "#f8fafc", "surface": "#ffffff", "accent": "#2563eb", "text": "#0f172a"}, + ), + DesignTemplate( + template_id="ledger_focus", + name="Ledger Focus", + description="Structured dashboard styling for finance and tabular control views.", + tokens={"background": "#f5f1e8", "surface": "#fffaf0", "accent": "#b45309", "text": "#292524"}, + ), +) + + +def list_design_template_options() -> dict[str, object]: + return {"templates": [template.to_option_payload() for template in _TEMPLATES]} + + +def select_design_template(template_id: str, current_selection: DesignTemplateSelection | None = None) -> DesignTemplateSelection: + _ = current_selection + template = _template_by_id(template_id) + return DesignTemplateSelection(template_id=template.template_id, name=template.name, tokens=dict(template.tokens)) + + +def apply_design_template_to_canvas_preview(canvas: DashboardCanvas, selection: object) -> dict[str, object]: + if not isinstance(selection, DesignTemplateSelection): + raise RuntimeError("DESIGN_TEMPLATE_SELECTION_REQUIRED") + template = _template_by_id(selection.template_id) + canonical_selection = DesignTemplateSelection(template_id=template.template_id, name=template.name, tokens=dict(template.tokens)) + return {"template": canonical_selection.to_payload(), "canvas": canvas.to_payload()} + + +def _template_by_id(template_id: str) -> DesignTemplate: + normalized_id = str(template_id or "").strip() + for template in _TEMPLATES: + if template.template_id == normalized_id: + return template + raise RuntimeError(f"DESIGN_TEMPLATE_NOT_ALLOWED:{template_id}") diff --git a/skill_scripts/dynamic_design_brief.py b/skill_scripts/dynamic_design_brief.py new file mode 100644 index 0000000..58a75e5 --- /dev/null +++ b/skill_scripts/dynamic_design_brief.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Mapping + + +SCHEMA_VERSION = "wferp.design-brief.v1" +REQUIRED_TOP_LEVEL_KEYS = [ + "schema_version", + "report_intent", + "catalog_guardrail", + "target_audience", + "layout_recipe", + "chart_recipe", + "table_recipe", + "interaction_recipe", + "visual_direction", + "embedded_data_policy", +] +TABLE_FEATURES = ["filter", "sort", "drilldown", "column_visibility"] +PERIOD_COLUMN_MARKERS = ("period", "month", "date", "月份", "期間", "日期", "年月") + + +def _package_columns(package: Mapping[str, Any]) -> list[str]: + data_profile = package.get("data_profile") + if isinstance(data_profile, Mapping) and isinstance(data_profile.get("columns"), list): + return [str(column) for column in data_profile["columns"]] + + datasets = package.get("datasets") + if isinstance(datasets, Mapping) and isinstance(datasets.get("columns"), list): + return [str(column) for column in datasets["columns"]] + + return [] + + +def _has_period_column(columns: list[str]) -> bool: + for column in columns: + normalized = column.lower() + if any(marker in normalized for marker in PERIOD_COLUMN_MARKERS): + return True + return False + + +def _chart_recipe(package: Mapping[str, Any]) -> list[dict[str, str]]: + charts = [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view.", + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories.", + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review.", + }, + ] + if _has_period_column(_package_columns(package)): + return [ + { + "id": "period-expense-trend", + "type": "line", + "purpose": "Reveal period trend and month-over-month movement.", + }, + *charts, + ] + return charts + + +def _merge_overrides(base: dict[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in overrides.items(): + if isinstance(value, Mapping) and isinstance(merged.get(key), dict): + merged[key] = _merge_overrides(merged[key], value) + else: + merged[key] = deepcopy(value) + return merged + + +def build_design_brief( + package: Mapping[str, Any], + *, + user_overrides: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + package_catalog_guardrail = package.get("catalog_guardrail") + if isinstance(package_catalog_guardrail, str): + catalog_guardrail: Any = package_catalog_guardrail.strip() or "financial-control" + elif package_catalog_guardrail is None: + catalog_guardrail = "financial-control" + else: + catalog_guardrail = package_catalog_guardrail + prompt = str(package.get("prompt") or "") + report_type = str(package.get("report_type") or "管理摘要") + row_count = 0 + data_profile = package.get("data_profile") + if isinstance(data_profile, Mapping) and isinstance(data_profile.get("row_count"), int): + row_count = data_profile["row_count"] + + brief = { + "schema_version": SCHEMA_VERSION, + "report_intent": { + "prompt": prompt, + "report_type": report_type, + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals.", + }, + "catalog_guardrail": catalog_guardrail, + "target_audience": { + "role": "finance controller", + "needs": ["budget variance review", "expense concentration review", "audit follow-up"], + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table", + ], + "density": "analysis-first", + }, + "chart_recipe": _chart_recipe(package), + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": TABLE_FEATURES, + "row_count": row_count, + } + ], + "interaction_recipe": { + "filters": _package_columns(package), + "drilldowns": ["department", "category", "document"], + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": ["variance", "outliers", "approval-ready summary"], + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000, + }, + } + if user_overrides: + brief = _merge_overrides(brief, user_overrides) + return {key: brief[key] for key in REQUIRED_TOP_LEVEL_KEYS if key in brief} + + +def validate_design_brief(brief: Mapping[str, Any]) -> dict[str, Any]: + errors: list[str] = [] + for key in REQUIRED_TOP_LEVEL_KEYS: + if not brief.get(key): + errors.append(key) + + if brief.get("schema_version") != SCHEMA_VERSION and "schema_version" not in errors: + errors.append("schema_version") + + catalog_guardrail = brief.get("catalog_guardrail") + if ( + (not isinstance(catalog_guardrail, str) or not catalog_guardrail.strip()) + and "catalog_guardrail" not in errors + ): + errors.append("catalog_guardrail") + + for key in [ + "report_intent", + "target_audience", + "layout_recipe", + "interaction_recipe", + "visual_direction", + "embedded_data_policy", + ]: + if key not in errors and not isinstance(brief.get(key), Mapping): + errors.append(key) + + chart_recipe = brief.get("chart_recipe") + if not isinstance(chart_recipe, list): + errors.append("chart_recipe") + else: + for index, chart in enumerate(chart_recipe): + if not isinstance(chart, Mapping): + errors.append(f"chart_recipe[{index}]") + continue + chart_id = chart.get("id") + if not isinstance(chart_id, str) or not chart_id.strip(): + errors.append(f"chart_recipe[{index}].id") + chart_type = chart.get("type") + if not isinstance(chart_type, str) or not chart_type.strip(): + errors.append(f"chart_recipe[{index}].type") + purpose = chart.get("purpose") + if not isinstance(purpose, str) or not purpose.strip(): + errors.append(f"chart_recipe[{index}].purpose") + + table_recipe = brief.get("table_recipe") + if not isinstance(table_recipe, list): + errors.append("table_recipe") + else: + for index, table in enumerate(table_recipe): + if not isinstance(table, Mapping): + errors.append(f"table_recipe[{index}]") + continue + table_id = table.get("id") + if not isinstance(table_id, str) or not table_id.strip(): + errors.append(f"table_recipe[{index}].id") + table_type = table.get("type") + if not isinstance(table_type, str) or not table_type.strip(): + errors.append(f"table_recipe[{index}].type") + if table.get("features") != TABLE_FEATURES: + errors.append(f"table_recipe[{index}].features") + + embedded_data_policy = brief.get("embedded_data_policy") + mode = embedded_data_policy.get("mode") if isinstance(embedded_data_policy, Mapping) else None + if mode != "smart-tiered": + errors.append("embedded_data_policy.mode") + + return {"valid": not errors, "errors": errors} diff --git a/skill_scripts/excel_intake.py b/skill_scripts/excel_intake.py new file mode 100644 index 0000000..9bc4ac6 --- /dev/null +++ b/skill_scripts/excel_intake.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import re +import zipfile +from pathlib import Path +import xml.etree.ElementTree as ET +from typing import Any + + +MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + + +@dataclass(frozen=True) +class DatabaseFieldRequirement: + table: str + field: str + display_name: str + description: str = "" + + +@dataclass(frozen=True) +class FormulaFieldRequirement: + name: str + formula: str + description: str = "" + + +@dataclass(frozen=True) +class ReportFieldRequirement: + output_name: str + source_name: str = "" + formula: str = "" + + +@dataclass(frozen=True) +class FormulaLineage: + field_name: str + formula: str + references: list[dict[str, str]] + + +@dataclass(frozen=True) +class FormulaCell: + address: str + formula: str + + +@dataclass(frozen=True) +class XlsxSheet: + rows: list[list[str]] + formula_cells: list[FormulaCell] + + +@dataclass(frozen=True) +class WorkbookRequirement: + database_fields: list[DatabaseFieldRequirement] + formula_fields: list[FormulaFieldRequirement] + report_fields: list[ReportFieldRequirement] + formula_lineage: list[FormulaLineage] + warnings: list[str] + report_outputs: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + lineage_by_formula = { + (item.field_name, item.formula): item.references for item in self.formula_lineage + } + return { + "source_type": "excel", + "db_fields": [ + { + "label": field.display_name or field.field, + "required": True, + "matched_schema_field": None, + } + for field in self.database_fields + ], + "formula_fields": [ + { + "name": field.name, + "formula": field.formula, + "dependencies": [ + reference["name"] + for reference in lineage_by_formula.get((field.name, field.formula), []) + ], + "requires_user_confirmation": True, + } + for field in self.formula_fields + ], + "report_outputs": self.report_outputs, + "warnings": self.warnings, + } + + +def _text(element: ET.Element | None) -> str: + if element is None or element.text is None: + return "" + return element.text.strip() + + +def _load_shared_strings(archive: zipfile.ZipFile) -> list[str]: + try: + raw = archive.read("xl/sharedStrings.xml") + except KeyError: + return [] + root = ET.fromstring(raw) + strings = [] + for item in root.findall(f"{{{MAIN_NS}}}si"): + parts = [node.text or "" for node in item.findall(f".//{{{MAIN_NS}}}t")] + strings.append("".join(parts)) + return strings + + +def _cell_column(ref: str) -> int: + letters = "".join(ch for ch in ref if ch.isalpha()) + value = 0 + for ch in letters: + value = value * 26 + (ord(ch.upper()) - 64) + return value + + +def _read_cell_value(cell: ET.Element, shared_strings: list[str]) -> str: + formula = cell.find(f"{{{MAIN_NS}}}f") + if formula is not None: + return "=" + _text(formula).lstrip("=") + + cell_type = cell.attrib.get("t", "") + if cell_type == "inlineStr": + return _text(cell.find(f".//{{{MAIN_NS}}}t")) + + value = _text(cell.find(f"{{{MAIN_NS}}}v")) + if cell_type == "s" and value: + try: + return shared_strings[int(value)] + except (ValueError, IndexError): + return "" + return value + + +def _normalise_target(target: str) -> str: + target = target.lstrip("/") + if target.startswith("xl/"): + return target + return f"xl/{target}" + + +def _read_xlsx_sheets_with_metadata(path: Path) -> dict[str, XlsxSheet]: + with zipfile.ZipFile(path) as archive: + shared_strings = _load_shared_strings(archive) + workbook = ET.fromstring(archive.read("xl/workbook.xml")) + rel_root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + rel_targets = { + rel.attrib["Id"]: _normalise_target(rel.attrib["Target"]) + for rel in rel_root.findall(f"{{{REL_NS}}}Relationship") + } + + sheets: dict[str, XlsxSheet] = {} + for sheet in workbook.findall(f".//{{{MAIN_NS}}}sheet"): + name = sheet.attrib["name"] + rel_id = sheet.attrib[f"{{{DOC_REL_NS}}}id"] + sheet_root = ET.fromstring(archive.read(rel_targets[rel_id])) + rows: list[list[str]] = [] + formula_cells: list[FormulaCell] = [] + for row in sheet_root.findall(f".//{{{MAIN_NS}}}row"): + values: dict[int, str] = {} + for cell in row.findall(f"{{{MAIN_NS}}}c"): + ref = cell.attrib.get("r", "") + value = _read_cell_value(cell, shared_strings) + values[_cell_column(ref)] = value + if cell.find(f"{{{MAIN_NS}}}f") is not None: + formula_cells.append(FormulaCell(address=ref, formula=value)) + max_column = max(values, default=0) + rows.append([values.get(index, "") for index in range(1, max_column + 1)]) + sheets[name] = XlsxSheet(rows=rows, formula_cells=formula_cells) + return sheets + + +def _read_xlsx_sheets(path: Path) -> dict[str, list[list[str]]]: + return { + name: sheet.rows + for name, sheet in _read_xlsx_sheets_with_metadata(path).items() + } + + +def _rows_as_dicts(rows: list[list[str]]) -> list[dict[str, str]]: + if not rows: + return [] + headers = [str(value).strip() for value in rows[0]] + records = [] + for row in rows[1:]: + if not any(str(value).strip() for value in row): + continue + records.append( + { + headers[index]: str(row[index]).strip() if index < len(row) else "" + for index in range(len(headers)) + } + ) + return records + + +def _formula_references(formula: str) -> list[str]: + body = formula.lstrip("=") + body = re.sub(r'"[^"]*"|\'[^\']*\'', "", body) + tokens = re.findall(r"[\u4e00-\u9fffA-Za-z_][\u4e00-\u9fffA-Za-z0-9_]*", body) + seen: set[str] = set() + references = [] + for token in tokens: + upper = token.upper() + if upper in {"SUM", "AVG", "MIN", "MAX", "IF", "CASE", "WHEN"}: + continue + if token not in seen: + seen.add(token) + references.append(token) + return references + + +def _build_lineage( + field_name: str, + formula: str, + database_names: set[str], + formula_names: set[str], +) -> FormulaLineage: + references = [] + for name in _formula_references(formula): + if name in database_names: + source = "database-backed" + elif name in formula_names: + source = "formula-backed" + else: + source = "unresolved" + references.append({"name": name, "source": source}) + return FormulaLineage(field_name=field_name, formula=formula, references=references) + + +def parse_excel_requirement(path: str | Path) -> WorkbookRequirement: + workbook_path = Path(path) + sheet_data = _read_xlsx_sheets_with_metadata(workbook_path) + sheets = {name: sheet.rows for name, sheet in sheet_data.items()} + missing = [name for name in ("需求欄位", "自訂公式", "管理報表") if name not in sheets] + if missing: + raise ValueError("Missing required workbook sheets: " + ", ".join(missing)) + + database_fields = [ + DatabaseFieldRequirement( + table=row.get("表格", ""), + field=row.get("欄位", ""), + display_name=row.get("顯示名稱", ""), + description=row.get("說明", ""), + ) + for row in _rows_as_dicts(sheets["需求欄位"]) + ] + formula_fields = [ + FormulaFieldRequirement( + name=row.get("欄位名稱", ""), + formula=row.get("公式", ""), + description=row.get("說明", ""), + ) + for row in _rows_as_dicts(sheets["自訂公式"]) + ] + report_fields = [ + ReportFieldRequirement( + output_name=row.get("輸出欄位", ""), + source_name=row.get("來源欄位", ""), + formula=row.get("公式", ""), + ) + for row in _rows_as_dicts(sheets["管理報表"]) + ] + + database_names = {field.display_name for field in database_fields if field.display_name} + formula_names = {field.name for field in formula_fields if field.name} + lineage = [ + _build_lineage(field.name, field.formula, database_names, formula_names) + for field in formula_fields + if field.formula + ] + lineage.extend( + _build_lineage(field.output_name, field.formula, database_names, formula_names) + for field in report_fields + if field.formula + ) + + formula_field_keys = { + (field.name, field.formula) + for field in formula_fields + } + warnings = [ + f"Formula field '{item.field_name}' references unresolved source '{ref['name']}'." + for item in lineage + for ref in item.references + if ref["source"] == "unresolved" and (item.field_name, item.formula) not in formula_field_keys + ] + warnings.extend( + f"Formula field '{field.name}' appears to use an empty or shared formula that cannot be resolved locally." + for field in formula_fields + if field.formula.strip() == "=" + ) + warnings.extend( + f"Report field '{field.output_name}' appears to use an empty or shared formula that cannot be resolved locally." + for field in report_fields + if field.formula.strip() == "=" + ) + report_outputs = [ + { + "sheet": sheet_name, + "cells": [ + {"address": cell.address, "formula": cell.formula} + for cell in sheet.formula_cells + ], + } + for sheet_name, sheet in sheet_data.items() + if sheet_name not in {"需求欄位", "自訂公式"} and sheet.formula_cells + ] + + return WorkbookRequirement( + database_fields=database_fields, + formula_fields=formula_fields, + report_fields=report_fields, + formula_lineage=lineage, + report_outputs=report_outputs, + warnings=warnings, + ) + + +def build_excel_confirmation_payload(requirement: WorkbookRequirement) -> dict[str, list[dict[str, Any]] | list[str]]: + return { + "資料庫欄位": [ + { + "表格": field.table, + "欄位": field.field, + "顯示名稱": field.display_name, + "說明": field.description, + } + for field in requirement.database_fields + ], + "使用者公式欄位": [ + { + "欄位名稱": field.name, + "公式": field.formula, + "說明": field.description, + "公式來源": next( + ( + item.references + for item in requirement.formula_lineage + if item.field_name == field.name and item.formula == field.formula + ), + [], + ), + } + for field in requirement.formula_fields + ], + "報表輸出欄位": [ + { + "輸出欄位": field.output_name, + "來源欄位": field.source_name, + "公式": field.formula, + } + for field in requirement.report_fields + ], + "需使用者確認": requirement.warnings, + } diff --git a/skill_scripts/excel_workbook_exporter.mjs b/skill_scripts/excel_workbook_exporter.mjs new file mode 100644 index 0000000..72c9944 --- /dev/null +++ b/skill_scripts/excel_workbook_exporter.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const [, , payloadPath, outputPath, evidencePath, resultPath] = process.argv; +const artifactToolModule = process.env.WFERP_ARTIFACT_TOOL_MODULE; +if (!artifactToolModule) { + throw new Error("WFERP_ARTIFACT_TOOL_MODULE is required"); +} +const { SpreadsheetFile, Workbook } = await import(pathToFileURL(artifactToolModule).href); + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function asObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function normalizeCell(value) { + if (value === undefined) return null; + if (value === null) return null; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") return value; + return JSON.stringify(value); +} + +function inferColumns(rows) { + const columns = []; + for (const row of rows) { + for (const key of Object.keys(asObject(row))) { + if (!columns.includes(key)) columns.push(key); + } + } + return columns.length ? columns : ["value"]; +} + +function sanitizeSheetName(rawName, usedNames) { + const cleaned = String(rawName || "Report") + .replace(/[\[\]:*?/\\]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 31) || "Report"; + let name = cleaned; + let suffix = 2; + while (usedNames.has(name.toLowerCase())) { + const marker = ` ${suffix}`; + name = `${cleaned.slice(0, 31 - marker.length)}${marker}`; + suffix += 1; + } + usedNames.add(name.toLowerCase()); + return name; +} + +function normalizeSheets(payload) { + const sheets = asArray(payload.sheets); + if (sheets.length) return sheets; + const rows = asArray(payload.rows || payload.sample_rows || payload.embedded_rows); + if (rows.length) { + return [{ name: payload.name || "Report", columns: payload.columns || inferColumns(rows), rows }]; + } + return [ + { + name: "Report", + columns: ["message"], + rows: [{ message: "No tabular payload was provided." }], + }, + ]; +} + +async function buildWorkbook(payload) { + const workbook = Workbook.create(); + const usedNames = new Set(); + const evidence = { + status: "exported", + workbook_path: outputPath, + sheets: [], + verification: { + artifact_tool: "@oai/artifact-tool", + render_preview_path: "", + render_preview_status: "not_run", + formula_error_count: 0, + }, + }; + + for (const sheetPayload of normalizeSheets(payload)) { + const sourceRows = asArray(sheetPayload.rows || sheetPayload.sample_rows || sheetPayload.embedded_rows); + const columns = asArray(sheetPayload.columns).length ? asArray(sheetPayload.columns).map(String) : inferColumns(sourceRows); + const name = sanitizeSheetName(sheetPayload.name || sheetPayload.sheet_name || "Report", usedNames); + const sheet = workbook.worksheets.add(name); + sheet.showGridLines = false; + + const title = String(sheetPayload.title || name); + sheet.getRange("A1").values = [[title]]; + sheet.getRange("A1").format = { + font: { bold: true, color: "#111827" }, + fill: "#F8FAFC", + }; + + const matrix = [ + columns, + ...sourceRows.map((row) => { + const objectRow = asObject(row); + return columns.map((column) => normalizeCell(objectRow[column])); + }), + ]; + const width = Math.max(columns.length, 1); + const height = Math.max(matrix.length, 1); + const tableRange = sheet.getRangeByIndexes(2, 0, height, width); + tableRange.values = matrix; + + sheet.getRangeByIndexes(2, 0, 1, width).format = { + fill: "#0F766E", + font: { bold: true, color: "#FFFFFF" }, + }; + try { + sheet.freezePanes.freezeRows(3); + tableRange.format.autofitColumns(); + tableRange.format.autofitRows(); + } catch (error) { + evidence.verification.autofit_warning = String(error?.message || error); + } + + evidence.sheets.push({ + name, + column_count: columns.length, + row_count: sourceRows.length, + formula_strategy: String(sheetPayload.formula_strategy || sheetPayload.formulaStrategy || "value-only"), + }); + } + + if (evidence.sheets.length) { + try { + const previewPath = path.join(path.dirname(evidencePath), "excel-preview.png"); + const preview = await workbook.render({ + sheetName: evidence.sheets[0].name, + autoCrop: "all", + scale: 1, + format: "png", + }); + await fs.writeFile(previewPath, new Uint8Array(await preview.arrayBuffer())); + evidence.verification.render_preview_path = previewPath; + evidence.verification.render_preview_status = "rendered"; + } catch (error) { + evidence.verification.render_preview_status = "warning"; + evidence.verification.render_preview_warning = String(error?.message || error); + } + } + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const xlsx = await SpreadsheetFile.exportXlsx(workbook); + await xlsx.save(outputPath); + return evidence; +} + +const payload = JSON.parse(await fs.readFile(payloadPath, "utf8")); +const result = await buildWorkbook(payload); +await fs.writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); +await fs.writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); diff --git a/skill_scripts/excel_workbook_exporter.py b/skill_scripts/excel_workbook_exporter.py new file mode 100644 index 0000000..c7fb678 --- /dev/null +++ b/skill_scripts/excel_workbook_exporter.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import zipfile +from typing import Any + + +class ExcelWorkbookExportError(RuntimeError): + pass + + +def _runtime_root() -> Path: + return Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" + + +def _node_executable() -> str: + found = shutil.which("node") + if found: + return found + bundled = _runtime_root() / "node" / "bin" / "node.exe" + if bundled.exists(): + return str(bundled) + raise ExcelWorkbookExportError("Node.js runtime was not found") + + +def _node_modules_source() -> Path: + configured = os.getenv("WFERP_NODE_MODULES") + if configured: + path = Path(configured) + if path.exists(): + return path + bundled = _runtime_root() / "node" / "node_modules" + if bundled.exists(): + return bundled + raise ExcelWorkbookExportError("Bundled node_modules was not found") + + +def _artifact_tool_module() -> Path: + module_path = _node_modules_source() / "@oai" / "artifact-tool" / "dist" / "artifact_tool.mjs" + if module_path.exists(): + return module_path + raise ExcelWorkbookExportError("artifact-tool module was not found") + + +def _prepare_node_workdir(work_dir: Path) -> Path: + work_dir.mkdir(parents=True, exist_ok=True) + builder_source = Path(__file__).with_name("excel_workbook_exporter.mjs") + builder_target = work_dir / "excel_workbook_exporter.mjs" + shutil.copy2(builder_source, builder_target) + return builder_target + + +def _verify_xlsx(path: Path) -> dict[str, Any]: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + return { + "xlsx_contains_workbook_xml": "xl/workbook.xml" in names, + "xlsx_contains_shared_strings": "xl/sharedStrings.xml" in names, + "xlsx_file_count": len(names), + } + + +def export_workbook( + payload: dict[str, Any], + output_path: str | Path, + *, + evidence_path: str | Path | None = None, +) -> dict[str, Any]: + output = Path(output_path).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + evidence = (Path(evidence_path) if evidence_path else output.with_suffix(".evidence.json")).resolve() + evidence.parent.mkdir(parents=True, exist_ok=True) + work_dir = evidence.parent / ".excel-workbook-exporter" + builder = _prepare_node_workdir(work_dir) + payload_path = work_dir / "payload.json" + result_path = work_dir / "result.json" + payload_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + env = dict(os.environ) + env["WFERP_ARTIFACT_TOOL_MODULE"] = str(_artifact_tool_module()) + completed = subprocess.run( + [_node_executable(), str(builder), str(payload_path), str(output), str(evidence), str(result_path)], + cwd=work_dir, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + if completed.returncode != 0: + message = (completed.stderr or "").strip() or (completed.stdout or "").strip() or "Excel workbook export failed" + raise ExcelWorkbookExportError(message) + if not result_path.exists(): + raise ExcelWorkbookExportError("Excel workbook exporter did not write a result file") + + result = json.loads(result_path.read_text(encoding="utf-8")) + if not isinstance(result, dict): + raise ExcelWorkbookExportError("Excel workbook exporter result must be an object") + verification = dict(result.get("verification") or {}) + verification.update(_verify_xlsx(output)) + result["verification"] = verification + evidence.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return result diff --git a/skill_scripts/execution_validator.py b/skill_scripts/execution_validator.py new file mode 100644 index 0000000..36f596b --- /dev/null +++ b/skill_scripts/execution_validator.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class AggregateExpectation: + operation: str + column: str + expected_value: float + tolerance: float = 0.0 + + +@dataclass(frozen=True) +class ExecutionExpectation: + required_columns: list[str] = field(default_factory=list) + min_rows: int = 0 + max_rows: int | None = None + aggregates: list[AggregateExpectation] = field(default_factory=list) + + +def _to_float(value: Any) -> float: + return float(value) if value is not None else 0.0 + + +def _compute_aggregate(rows: list[dict[str, Any]], operation: str, column: str) -> float: + values = [_to_float(row.get(column)) for row in rows] + if not values: + return 0.0 + op = operation.lower() + if op == "sum": + return float(sum(values)) + if op == "min": + return float(min(values)) + if op == "max": + return float(max(values)) + if op == "avg": + return float(sum(values) / len(values)) + raise RuntimeError("UNKNOWN_AGGREGATE_OPERATION") + + +def validate_execution_result(rows: list[dict[str, Any]], expectation: ExecutionExpectation) -> tuple[bool, str]: + row_count = len(rows) + if row_count < expectation.min_rows: + return False, "ROWCOUNT_TOO_LOW" + if expectation.max_rows is not None and row_count > expectation.max_rows: + return False, "ROWCOUNT_TOO_HIGH" + + if expectation.required_columns: + if not rows: + return False, "MISSING_REQUIRED_COLUMN" + actual_columns = set(rows[0].keys()) + if any(column not in actual_columns for column in expectation.required_columns): + return False, "MISSING_REQUIRED_COLUMN" + + for aggregate in expectation.aggregates: + if any(aggregate.column not in row for row in rows): + return False, "MISSING_AGGREGATE_COLUMN" + actual_value = _compute_aggregate(rows, aggregate.operation, aggregate.column) + if abs(actual_value - aggregate.expected_value) > aggregate.tolerance: + return False, "AGGREGATE_MISMATCH" + + return True, "OK" + + +def execute_and_validate(sql: str, db_client, expectation: ExecutionExpectation) -> tuple[bool, str, list[dict[str, Any]]]: + rows = db_client.execute_readonly(sql) + ok, code = validate_execution_result(rows, expectation) + return ok, code, rows diff --git a/skill_scripts/formula_sqlite_translator.py b/skill_scripts/formula_sqlite_translator.py new file mode 100644 index 0000000..0d8d2d6 --- /dev/null +++ b/skill_scripts/formula_sqlite_translator.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import re +from typing import Any + + +_CELL_RE = re.compile(r"([A-Z]+)\d+$", re.IGNORECASE) +_SAFE_IDENTIFIER_RE = r'"(?:[^"]|"")+"' +_SAFE_MAPPED_SQL_RE = re.compile( + rf"\s*(?:{_SAFE_IDENTIFIER_RE}|raw\.{_SAFE_IDENTIFIER_RE}|[+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$" +) + + +def _unsupported(formula: str, function: str, *, strict: bool) -> dict[str, Any]: + if strict: + if function == "UNKNOWN": + raise ValueError(f"Unsupported Excel formula: {formula}") + raise ValueError(f"Unsupported Excel formula function: {function}") + return {"status": "unsupported", "function": function, "formula": formula} + + +def _function_name(text: str) -> str: + match = re.match(r"\s*([A-Z][A-Z0-9.]*)\s*\(", text, re.IGNORECASE) + return match.group(1).upper() if match else "UNKNOWN" + + +def _sql_for_cell(cell_ref: str, column_map: dict[str, str]) -> str: + match = _CELL_RE.fullmatch(cell_ref.strip()) + if not match: + raise ValueError(f"Unsupported cell reference: {cell_ref}") + + column = match.group(1).upper() + if column not in column_map: + raise ValueError(f"No SQLite mapping for Excel column: {column}") + + mapped = column_map[column] + if not _SAFE_MAPPED_SQL_RE.fullmatch(mapped): + raise ValueError(f"Unsafe SQLite mapping for Excel column: {column}") + return mapped.strip() + + +def _sql_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def translate_formula( + formula: str, + column_map: dict[str, str], + *, + strict: bool = True, +) -> str | dict[str, Any]: + text = formula.strip() + if text.startswith("="): + text = text[1:].strip() + + arithmetic = re.fullmatch( + r"([A-Z]+\d+)\s*([-*])\s*([A-Z]+\d+)", + text, + re.IGNORECASE, + ) + if arithmetic: + left, operator, right = arithmetic.groups() + return f"({_sql_for_cell(left, column_map)} {operator} {_sql_for_cell(right, column_map)})" + + mid = re.fullmatch( + r"MID\(\s*([A-Z]+\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", + text, + re.IGNORECASE, + ) + if mid: + cell, start, length = mid.groups() + return f"SUBSTR({_sql_for_cell(cell, column_map)}, {start}, {length})" + + if_left = re.fullmatch( + r'IF\(\s*LEFT\(\s*([A-Z]+\d+)\s*,\s*(\d+)\s*\)\s*=\s*"([^"]*)"\s*,\s*"([^"]*)"\s*,\s*LEFT\(\s*([A-Z]+\d+)\s*,\s*(\d+)\s*\)\s*\)', + text, + re.IGNORECASE, + ) + if if_left: + test_cell, test_len, expected, true_value, else_cell, else_len = if_left.groups() + return ( + f"CASE WHEN SUBSTR({_sql_for_cell(test_cell, column_map)}, 1, {test_len}) = {_sql_string(expected)} " + f"THEN {_sql_string(true_value)} ELSE SUBSTR({_sql_for_cell(else_cell, column_map)}, 1, {else_len}) END" + ) + + return _unsupported(formula, _function_name(text), strict=strict) diff --git a/skill_scripts/governed_query.py b/skill_scripts/governed_query.py new file mode 100644 index 0000000..50c2276 --- /dev/null +++ b/skill_scripts/governed_query.py @@ -0,0 +1,214 @@ +from dataclasses import asdict, dataclass, field, replace +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any + +from skill_scripts.execution_validator import ExecutionExpectation, execute_and_validate +from skill_scripts.metadata_validator import validate_metadata_references +from skill_scripts.prompt_sql_consistency import validate_prompt_sql_consistency +from skill_scripts.sql2000_guard import validate_sql +from skill_scripts.sql_router import RoutingOptions, route_generate_sql + + +@dataclass(frozen=True) +class QueryEvidence: + prompt: str + sql: str + route: str + route_reason: str + validation_status: str + execution_status: str + execution_timestamp: str + returned_columns: list[str] = field(default_factory=list) + row_count: int = 0 + sample_rows: list[dict[str, Any]] = field(default_factory=list) + required_columns: list[str] = field(default_factory=list) + aggregate_checks: list[dict[str, Any]] = field(default_factory=list) + candidate_sql: str = "" + + +@dataclass(frozen=True) +class GovernedQueryResult: + evidence: QueryEvidence + approval_state: str = "ready_for_analyst_approval" + rejection_reason: str = "" + + +def _serialize_aggregate_checks(expectation: ExecutionExpectation) -> list[dict[str, Any]]: + return [asdict(aggregate) for aggregate in expectation.aggregates] + + +def _json_safe_value(value: Any) -> Any: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _json_safe_rows(rows: list[dict[str, Any]], sample_rows_limit: int) -> list[dict[str, Any]]: + sample_limit = max(0, int(sample_rows_limit)) + return [{key: _json_safe_value(value) for key, value in row.items()} for row in rows[:sample_limit]] + + +def _build_evidence( + prompt: str, + sql: str, + meta: dict[str, Any], + expectation: ExecutionExpectation, + rows: list[dict[str, Any]], + sample_rows_limit: int, +) -> QueryEvidence: + returned_columns = list(rows[0].keys()) if rows else [] + return QueryEvidence( + prompt=prompt, + sql=sql, + route=str(meta.get("route", "")), + route_reason=str(meta.get("reason", "")), + validation_status="OK", + execution_status="OK", + execution_timestamp=datetime.now(timezone.utc).isoformat(), + returned_columns=returned_columns, + row_count=len(rows), + sample_rows=_json_safe_rows(rows, sample_rows_limit), + required_columns=list(expectation.required_columns), + aggregate_checks=_serialize_aggregate_checks(expectation), + candidate_sql=str(meta.get("candidate_sql", "")), + ) + + +def run_governed_query( + prompt: str, + bundle: dict[str, Any], + options: RoutingOptions, + db_client, + sample_rows_limit: int = 5, +) -> GovernedQueryResult: + if db_client is None: + raise RuntimeError("DB_CLIENT_REQUIRED") + + config = getattr(db_client, "config", None) + env = str(getattr(config, "env", "")).strip().lower() if config is not None else "" + if env != "test" and not options.allow_non_test_execution: + raise RuntimeError("DB_ENV_NOT_TEST") + + if hasattr(db_client, "health_check"): + ok_health, code_health = db_client.health_check() + if not ok_health: + raise RuntimeError(str(code_health)) + + governed_options = replace(options, validate_execution=False) + sql, meta = route_generate_sql( + prompt=prompt, + bundle=bundle, + options=governed_options, + db_client=db_client, + ) + + ok_policy, code_policy = validate_sql(sql) + if not ok_policy: + raise RuntimeError(code_policy) + + ok_meta, code_meta = validate_metadata_references(sql, bundle) + if not ok_meta: + raise RuntimeError(code_meta) + + ok_consistency, code_consistency = validate_prompt_sql_consistency(prompt, sql) + if not ok_consistency: + raise RuntimeError(code_consistency) + + expectation = governed_options.execution_expectation or ExecutionExpectation() + ok_exec, code_exec, rows = execute_and_validate(sql, db_client, expectation) + if not ok_exec: + raise RuntimeError(code_exec) + + evidence = _build_evidence( + prompt=prompt, + sql=sql, + meta=meta, + expectation=expectation, + rows=rows, + sample_rows_limit=sample_rows_limit, + ) + return GovernedQueryResult(evidence=evidence) + + +def capture_live_llm_query_evidence( + prompt: str, + bundle: dict[str, Any], + options: RoutingOptions, + db_client: object, + sample_rows_limit: int = 5, +) -> dict[str, object]: + try: + result = run_governed_query( + prompt=prompt, + bundle=bundle, + options=options, + db_client=db_client, + sample_rows_limit=sample_rows_limit, + ) + except RuntimeError as exc: + code = str(exc) + return { + "prompt": prompt, + "status": "failed", + "failure_classification": _classify_live_llm_failure(code), + "blocking": _is_live_llm_product_failure(code), + "evidence_type": "live_llm_governed_query", + "error_code": code, + } + return { + "prompt": prompt, + "status": "passed", + "failure_classification": "none", + "blocking": False, + "evidence_type": "live_llm_governed_query", + "route": result.evidence.route, + "validation_status": result.evidence.validation_status, + "execution_status": result.evidence.execution_status, + "returned_columns": result.evidence.returned_columns, + "row_count": result.evidence.row_count, + } + + +def _classify_live_llm_failure(code: str) -> str: + if code.startswith(("LLM_", "OPENCODE_")): + return "llm_availability" + if code in {"DB_CLIENT_REQUIRED", "DB_ENV_NOT_TEST"} or code.startswith("DB_"): + return "infra" + if code in {"MISSING_REQUIRED_COLUMN", "ROW_COUNT_TOO_LOW", "ROW_COUNT_TOO_HIGH", "AGGREGATE_MISMATCH", "MISSING_AGGREGATE_COLUMN"}: + return "test_data_seed" + return "product" + + +def _is_live_llm_product_failure(code: str) -> bool: + return _classify_live_llm_failure(code) == "product" + + +def approve_query_result(result: GovernedQueryResult) -> GovernedQueryResult: + if result.approval_state != "ready_for_analyst_approval": + raise RuntimeError("QUERY_RESULT_NOT_READY_FOR_APPROVAL") + return GovernedQueryResult( + evidence=result.evidence, + approval_state="approved_for_chart_spec", + rejection_reason="", + ) + + +def reject_query_result(result: GovernedQueryResult, reason: str) -> GovernedQueryResult: + if result.approval_state != "ready_for_analyst_approval": + raise RuntimeError("QUERY_RESULT_NOT_READY_FOR_APPROVAL") + rejection_reason = str(reason or "").strip() + if not rejection_reason: + raise RuntimeError("REJECTION_REASON_REQUIRED") + return GovernedQueryResult( + evidence=result.evidence, + approval_state="rejected", + rejection_reason=rejection_reason, + ) + + +def require_query_result_approved_for_chart_spec(result: GovernedQueryResult) -> None: + if result.approval_state != "approved_for_chart_spec": + raise RuntimeError("QUERY_RESULT_NOT_APPROVED") diff --git a/skill_scripts/harness_state_machine.py b/skill_scripts/harness_state_machine.py new file mode 100644 index 0000000..682b42d --- /dev/null +++ b/skill_scripts/harness_state_machine.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict, dataclass +from typing import Any + + +USER_STEP_MAPPING: dict[int, dict[str, Any]] = { + 1: { + "title": "Source-to-Output Logic", + "label": "source_to_output_logic", + "phases": [0, 1, 2, 3], + }, + 2: { + "title": "SQL Query", + "label": "sql_query", + "phases": [4, 5], + }, + 3: { + "title": "Data Result and Report Design", + "label": "data_result_and_report_design", + "phases": [6, 7, 8, 9], + }, + 4: { + "title": "Final Delivery", + "label": "final_delivery", + "phases": [10, 11, 12], + }, +} + + +GATE_DEFINITIONS: dict[str, dict[str, Any]] = { + "phase_3_field_formula": { + "user_step": 1, + "required_artifacts": ["checkpoints/01b_field_formula_classification.json"], + "required_validators": [ + "requirement_understanding_reviewer", + "schema_mapping_reviewer", + ], + "confirmation_checkpoint": "field_formula_classification", + "allowed_next_actions": ["generate_sql", "request_changes"], + }, + "phase_4_sql_review": { + "user_step": 2, + "required_artifacts": ["sql/query.sql", "checkpoints/02_sql_review.json"], + "required_validators": ["sql_safety_reviewer", "schema_mapping_reviewer"], + "confirmation_checkpoint": "sql_review", + "allowed_next_actions": ["execute_select", "request_changes"], + }, + "phase_6_data_preview": { + "user_step": 3, + "required_artifacts": [ + "checkpoints/03a_raw_data_preview.json", + "checkpoints/03b_enriched_data_preview.json", + ], + "required_validators": ["db_execution_reviewer", "data_preview_reviewer"], + "confirmation_checkpoint": "enriched_data_preview", + "allowed_next_actions": ["render_report_design", "request_changes"], + }, + "phase_12_delivery": { + "user_step": 4, + "required_artifacts": [ + "report/delivery/report.html", + "report/delivery/report.xlsx", + "review/final-review.json", + ], + "required_validators": [ + "report_content_reviewer", + "visual_taste_reviewer", + "react_technical_reviewer", + "delivery_reviewer", + ], + "confirmation_checkpoint": "final_review", + "allowed_next_actions": ["deliver", "request_changes"], + }, +} + + +class GateBlockedError(RuntimeError): + def __init__(self, gate: str, evaluation: dict[str, Any]) -> None: + self.gate = gate + self.evaluation = evaluation + reasons = ", ".join(evaluation.get("blocking_reason", [])) or "gate is blocked" + super().__init__(f"{gate} cannot advance: {reasons}") + + +@dataclass(frozen=True) +class GateEvaluation: + gate: str + status: str + user_step: int + required_artifacts: list[str] + required_validators: list[str] + confirmation: dict[str, Any] + allowed_next_actions: list[str] + blocking_reason: list[str] + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data.pop("gate") + return data + + +def initialize_state_machine(state: dict[str, Any]) -> dict[str, Any]: + initialized = deepcopy(state) + initialized.setdefault("current_user_step", 1) + initialized.setdefault("current_internal_phase", 0) + initialized.setdefault("user_step_mapping", deepcopy(USER_STEP_MAPPING)) + initialized.setdefault("artifact_status", {}) + initialized.setdefault("validator_results", []) + initialized.setdefault("confirmation_identity", {}) + initialized.setdefault("blocking_repair_request", None) + initialized.setdefault("delivery_status", "not_ready") + initialized.setdefault("allowed_next_actions", ["prepare_source_logic"]) + initialized["gate_status"] = _merge_gate_status(initialized) + initialized["phase_status"] = { + gate: entry["status"] for gate, entry in initialized["gate_status"].items() + } + initialized["required_artifacts"] = { + gate: deepcopy(definition["required_artifacts"]) + for gate, definition in GATE_DEFINITIONS.items() + } + initialized["required_validators"] = { + gate: deepcopy(definition["required_validators"]) + for gate, definition in GATE_DEFINITIONS.items() + } + return initialized + + +def evaluate_gate(state: dict[str, Any], gate: str) -> dict[str, Any]: + if gate not in GATE_DEFINITIONS: + raise KeyError(f"Unknown gate: {gate}") + + definition = GATE_DEFINITIONS[gate] + blocking_reason: list[str] = [] + + blocking_repair_request = state.get("blocking_repair_request") + if blocking_repair_request: + if not isinstance(blocking_repair_request, dict) or blocking_repair_request.get("gate") in (None, gate): + blocking_reason.append("blocking_repair_request") + + artifact_status = state.get("artifact_status") or {} + for artifact in definition["required_artifacts"]: + if not _artifact_is_present(artifact_status.get(artifact)): + blocking_reason.append(f"missing_artifact:{artifact}") + + validator_results = _validator_status_by_role(state.get("validator_results") or []) + for validator in definition["required_validators"]: + if not _validator_passed(validator_results.get(validator)): + blocking_reason.append(f"missing_validator:{validator}") + + checkpoint_id = definition["confirmation_checkpoint"] + confirmation = _confirmation_for_checkpoint(state.get("confirmation_identity") or {}, checkpoint_id) + if confirmation is None: + blocking_reason.append(f"missing_confirmation:{checkpoint_id}") + payload_hash = _checkpoint_payload_hash(state, checkpoint_id) + else: + payload_hash = confirmation.get("payload_hash") if isinstance(confirmation, dict) else None + + status = "complete" if not blocking_reason else "blocked" + return GateEvaluation( + gate=gate, + status=status, + user_step=definition["user_step"], + required_artifacts=deepcopy(definition["required_artifacts"]), + required_validators=deepcopy(definition["required_validators"]), + confirmation={ + "required": True, + "checkpoint_id": checkpoint_id, + "payload_hash": payload_hash, + }, + allowed_next_actions=deepcopy(definition["allowed_next_actions"]), + blocking_reason=blocking_reason, + ).to_dict() + + +def assert_can_advance(state: dict[str, Any], gate: str) -> dict[str, Any]: + evaluation = evaluate_gate(state, gate) + if evaluation["status"] != "complete": + raise GateBlockedError(gate, evaluation) + return evaluation + + +def _artifact_is_present(status: Any) -> bool: + if isinstance(status, dict): + return status.get("status") in ("complete", "ready", "present", "pass", "passed") or status.get("present") is True + return status in ("complete", "ready", "present", "pass", "passed", True) + + +def _validator_passed(status: Any) -> bool: + if isinstance(status, dict): + status = status.get("status") + return status in ("pass", "passed") + + +def _confirmation_for_checkpoint(confirmation_identity: dict[str, Any], checkpoint_id: str) -> dict[str, Any] | None: + confirmation = confirmation_identity.get(checkpoint_id) + if not isinstance(confirmation, dict): + return None + if confirmation.get("checkpoint_id") not in (None, checkpoint_id): + return None + if not confirmation.get("confirmation_id"): + return None + return confirmation + + +def _checkpoint_payload_hash(state: dict[str, Any], checkpoint_id: str) -> str | None: + for checkpoint in state.get("checkpoints") or []: + if not isinstance(checkpoint, dict): + continue + if checkpoint.get("checkpoint") != checkpoint_id and checkpoint.get("checkpoint_id") != checkpoint_id: + continue + payload_hash = checkpoint.get("payload_hash") + if isinstance(payload_hash, str) and payload_hash: + return payload_hash + return None + + +def _validator_status_by_role(validator_results: list[Any]) -> dict[str, Any]: + statuses: dict[str, Any] = {} + for result in validator_results: + if not isinstance(result, dict): + continue + name = result.get("role") or result.get("validator") + if name: + statuses[name] = result + return statuses + + +def _merge_gate_status(state: dict[str, Any]) -> dict[str, Any]: + existing_gate_status = state.get("gate_status") or {} + gate_status: dict[str, Any] = {} + for gate, definition in GATE_DEFINITIONS.items(): + evaluated = evaluate_gate(state, gate) + existing = existing_gate_status.get(gate) + if not isinstance(existing, dict): + gate_status[gate] = evaluated + continue + + merged = deepcopy(existing) + merged.setdefault("status", evaluated["status"]) + merged.setdefault("blocking_reason", deepcopy(evaluated["blocking_reason"])) + merged["user_step"] = definition["user_step"] + merged["required_artifacts"] = deepcopy(definition["required_artifacts"]) + merged["required_validators"] = deepcopy(definition["required_validators"]) + merged["allowed_next_actions"] = deepcopy(definition["allowed_next_actions"]) + merged["confirmation"] = _merge_confirmation_metadata( + existing.get("confirmation"), + evaluated["confirmation"], + ) + gate_status[gate] = merged + return gate_status + + +def _merge_confirmation_metadata(existing: Any, evaluated: dict[str, Any]) -> dict[str, Any]: + if not isinstance(existing, dict): + return deepcopy(evaluated) + merged = deepcopy(existing) + merged["required"] = evaluated["required"] + merged["checkpoint_id"] = evaluated["checkpoint_id"] + if not merged.get("payload_hash"): + merged["payload_hash"] = evaluated.get("payload_hash") + return merged diff --git a/skill_scripts/html_self_validator.py b/skill_scripts/html_self_validator.py new file mode 100644 index 0000000..6dee875 --- /dev/null +++ b/skill_scripts/html_self_validator.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from pathlib import Path + +NETWORK_PATTERN = re.compile(r"""(?:src|href)=["'](https?://[^"']+)["']""", re.IGNORECASE) +SCRIPT_SRC_PATTERN = re.compile(r"""]*\bsrc\s*=""", re.IGNORECASE) +LINK_HREF_PATTERN = re.compile(r"""]*\bhref\s*=""", re.IGNORECASE) + + +def validate_single_html_static(path: str | Path) -> dict[str, object]: + html = Path(path).read_text(encoding="utf-8") + lowered = html.lower() + errors: list[str] = [] + network_references = NETWORK_PATTERN.findall(html) + + if SCRIPT_SRC_PATTERN.search(html): + errors.append("external_script") + if LINK_HREF_PATTERN.search(html): + errors.append("external_stylesheet") + if "fetch(" in html: + errors.append("network_fetch") + if "websocket" in lowered: + errors.append("websocket") + if any(token in lowered for token in ("password", "connection_string", "credential")): + errors.append("credentials") + if "__WFERP_REPORT_PACKAGE__" not in html: + errors.append("missing_package") + + return { + "valid": not errors, + "errors": errors, + "network_references": network_references, + } diff --git a/skill_scripts/intent_parser.py b/skill_scripts/intent_parser.py new file mode 100644 index 0000000..87cac66 --- /dev/null +++ b/skill_scripts/intent_parser.py @@ -0,0 +1,52 @@ +import re +from typing import Any + + +JsonDict = dict[str, Any] + + +NON_SELECT_PATTERNS = ( + r"更新", + r"刪除", + r"删除", + r"新增", + r"修改", + r"insert", + r"update", + r"delete", + r"create", + r"alter", + r"drop", +) + + +def parse_intent(prompt: str) -> JsonDict: + text = str(prompt or "").strip() + lowered = text.lower() + + top = 50 + explicit_top = False + m_top = re.search(r"\btop\s*(\d+)\b", lowered) + m_front = re.search(r"前\s*(\d+)\s*筆", text) + if m_top: + top = int(m_top.group(1)) + explicit_top = True + elif m_front: + top = int(m_front.group(1)) + explicit_top = True + + m_year = re.search(r"(? str: + value = str(text or "").strip() + if not value.startswith("```"): + return value + lines = value.splitlines() + if len(lines) < 3: + return value + if lines[0].startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return value + + +def build_llm_prompt(user_prompt: str, context_slice: JsonDict) -> str: + return ( + "You are a SQL generation engine for Workflow ERP. " + "Output JSON only with keys: sql, used_tables, assumptions, confidence. " + "Rules: SQL Server 2000 compatible, single statement, SELECT only, no CTE/window/offset/fetch/except/intersect. " + f"User prompt: {user_prompt}\n" + f"Context: {json.dumps(context_slice, ensure_ascii=False)}" + ) + + +def parse_llm_response(raw_text: str) -> JsonDict: + payload = json.loads(_strip_markdown_fence(raw_text)) + sql = str(payload.get("sql", "")).strip() + used_tables = [str(x).strip() for x in payload.get("used_tables", []) if str(x).strip()] + assumptions = [str(x).strip() for x in payload.get("assumptions", []) if str(x).strip()] + + try: + confidence = float(payload.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + + if confidence < 0.0: + confidence = 0.0 + if confidence > 1.0: + confidence = 1.0 + + return { + "sql": sql, + "used_tables": used_tables, + "assumptions": assumptions, + "confidence": confidence, + } + + +def _call_openai_compatible(model: str, prompt_text: str, timeout_sec: float) -> str: + base_url = os.getenv("LLM_BASE_URL", "").strip() + api_key = os.getenv("LLM_API_KEY", "").strip() + if not base_url or not api_key: + raise RuntimeError("LLM_PROVIDER_NOT_CONFIGURED") + + endpoint = base_url.rstrip("/") + "/chat/completions" + body = { + "model": model, + "messages": [{"role": "user", "content": prompt_text}], + "temperature": 0, + } + req = request.Request( + endpoint, + method="POST", + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + + try: + with request.urlopen(req, timeout=timeout_sec) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload["choices"][0]["message"]["content"]) + except error.HTTPError as exc: + raise RuntimeError(f"LLM_HTTP_ERROR:{exc.code}") from exc + except error.URLError as exc: + raise RuntimeError("LLM_NETWORK_ERROR") from exc + except TimeoutError as exc: + raise RuntimeError("LLM_TIMEOUT") from exc + except (KeyError, IndexError, TypeError, ValueError) as exc: + raise RuntimeError("LLM_BAD_RESPONSE") from exc + + +def _call_opencode_local(model: str, prompt_text: str, timeout_sec: float) -> str: + command = ["opencode", "run", "--format", "json"] + model_name = str(model or "").strip() + if model_name and model_name.lower() not in {"none", "default", "auto"}: + command.extend(["--model", model_name]) + command.append(prompt_text) + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=timeout_sec, + ) + except FileNotFoundError as exc: + raise RuntimeError("OPENCODE_CLI_NOT_INSTALLED") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError("LLM_TIMEOUT") from exc + + if result.returncode != 0: + raise RuntimeError("LLM_PROVIDER_ERROR") + + last_text = "" + for line in str(result.stdout or "").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if str(event.get("type", "")).strip().lower() != "text": + continue + part = event.get("part", {}) + if not isinstance(part, dict): + continue + text = str(part.get("text", "")).strip() + if text: + last_text = text + + if not last_text: + raise RuntimeError("LLM_BAD_RESPONSE") + return last_text + + +def _call_codex_oauth_local(model: str, prompt_text: str, timeout_sec: float) -> str: + temp_parent = Path(os.getenv("WFERP_LLM_TEMP_DIR", ".wferp-llm-temp")) + temp_parent.mkdir(parents=True, exist_ok=True) + temp_dir_path = temp_parent / f"codex-{uuid.uuid4().hex}" + temp_dir_path.mkdir(parents=True, exist_ok=False) + try: + prompt_path = os.path.join(temp_dir_path, "prompt.txt") + output_path = os.path.join(temp_dir_path, "last-message.txt") + with open(prompt_path, "w", encoding="utf-8") as handle: + handle.write(prompt_text) + command = [ + "codex", + "exec", + "--ephemeral", + "--sandbox", + "read-only", + "--ignore-rules", + "--output-last-message", + output_path, + ] + model_name = str(model or "").strip() + if model_name and model_name.lower() not in {"none", "default", "auto"}: + command.extend(["--model", model_name]) + command.append( + "Read the complete prompt from this UTF-8 file and follow it exactly: " + f"PROMPT_FILE={os.path.abspath(prompt_path)}" + ) + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=timeout_sec, + ) + except FileNotFoundError as exc: + raise RuntimeError("CODEX_CLI_NOT_INSTALLED") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError("LLM_TIMEOUT") from exc + + if result.returncode != 0: + raise RuntimeError("LLM_PROVIDER_ERROR") + + try: + last_message = open(output_path, encoding="utf-8").read().strip() + except OSError as exc: + raise RuntimeError("LLM_BAD_RESPONSE") from exc + + if not last_message: + fallback = str(result.stdout or "").strip() + if fallback: + return fallback + raise RuntimeError("LLM_BAD_RESPONSE") + return last_message + finally: + shutil.rmtree(temp_dir_path, ignore_errors=True) + + +def call_llm(provider: str, model: str, prompt_text: str, timeout_sec: float = 30.0) -> str: + provider_name = str(provider or "none").strip().lower() + + if provider_name in {"none", "", "disabled"}: + raise RuntimeError("LLM_PROVIDER_NOT_CONFIGURED") + + if provider_name == "mock": + return os.getenv( + "LLM_MOCK_RESPONSE", + json.dumps( + { + "sql": "SELECT TOP 50 * FROM [DSCSYS].[dbo].[ADMMC]", + "used_tables": ["ADMMC"], + "assumptions": ["mock provider fallback"], + "confidence": 0.4, + }, + ensure_ascii=False, + ), + ) + + if provider_name in {"openai-compatible", "openai_compatible", "openai"}: + return _call_openai_compatible(model=model, prompt_text=prompt_text, timeout_sec=timeout_sec) + + if provider_name in {"opencode", "open-code", "local-opencode", "native"}: + return _call_opencode_local(model=model, prompt_text=prompt_text, timeout_sec=timeout_sec) + + if provider_name in {"codex", "codex-oauth", "codex_oauth", "local-codex"}: + return _call_codex_oauth_local(model=model, prompt_text=prompt_text, timeout_sec=timeout_sec) + + raise RuntimeError("LLM_PROVIDER_UNSUPPORTED") diff --git a/skill_scripts/llm_workbook_classifier.py b/skill_scripts/llm_workbook_classifier.py new file mode 100644 index 0000000..213679c --- /dev/null +++ b/skill_scripts/llm_workbook_classifier.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from skill_scripts.data_dictionary import build_alias_index +from skill_scripts.excel_intake import _read_xlsx_sheets_with_metadata +from skill_scripts.llm_sql_generator import call_llm +from skill_scripts.schema_loader import load_schema_bundle +from skill_scripts.schema_metadata import SchemaMetadata +from skill_scripts.workbook_classifier import _column_name, _lookup_sheet + + +def _strip_fence(text: str) -> str: + value = str(text or "").strip() + if not value.startswith("```"): + return value + lines = value.splitlines() + if lines and lines[0].startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return value + + +def _field_ref(table_id: str, column_id: str) -> str: + return f"{table_id.strip().upper()}.{column_id.strip().upper()}" + + +def _schema_indexes(bundle: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: + tables = {str(row.get("TableID", "")).strip().upper(): row for row in bundle.get("tables", [])} + fields = { + _field_ref(str(row.get("TableID", "")), str(row.get("ID", ""))): row + for row in bundle.get("fields", []) + if str(row.get("TableID", "")).strip() and str(row.get("ID", "")).strip() + } + return tables, fields + + +def _workbook_columns(workbook_path: Path, primary_sheet: str) -> tuple[str, list[dict[str, Any]], set[str]]: + sheets = _read_xlsx_sheets_with_metadata(workbook_path) + sheet_name = primary_sheet or next(iter(sheets)) + if sheet_name not in sheets: + raise ValueError(f"Workbook sheet not found: {sheet_name}") + sheet = sheets[sheet_name] + headers = [str(value).strip() for value in sheet.rows[0]] if sheet.rows else [] + sample_row = sheet.rows[1] if len(sheet.rows) > 1 else [] + lookup_sheets: set[str] = set() + columns = [] + for index, header in enumerate(headers, start=1): + sample = sample_row[index - 1] if index <= len(sample_row) else "" + formula = sample if str(sample).startswith("=") else "" + lookup_sheet = _lookup_sheet(formula) + if lookup_sheet: + lookup_sheets.add(lookup_sheet) + columns.append( + { + "excel_column": _column_name(index), + "excel_header": header, + "sample_value": "" if formula else str(sample or "")[:120], + "formula": formula, + "lookup_sheet": lookup_sheet, + } + ) + return sheet_name, columns, lookup_sheets + + +def _candidate_schema_context(bundle: dict[str, Any], workbook_columns: list[dict[str, Any]]) -> dict[str, Any]: + tables_by_id, fields_by_ref = _schema_indexes(bundle) + alias_index = build_alias_index(bundle.get("fields", [])) + table_scores: dict[str, int] = {} + candidate_refs: set[str] = set() + + for column in workbook_columns: + header = str(column.get("excel_header", "")).strip().lower() + if not header: + continue + for ref in alias_index.get(header, [])[:20]: + candidate_refs.add(ref) + table_scores[ref.split(".", 1)[0].upper()] = table_scores.get(ref.split(".", 1)[0].upper(), 0) + 5 + + # Broad accounting context for the LLM to choose table groups from. This is retrieval, + # not final mapping: the LLM must still name candidate tables and justify them. + for table in bundle.get("tables", []): + table_id = str(table.get("TableID", "")).strip().upper() + table_name = str(table.get("TableName", "")).strip() + if table_id.startswith("ACT") or any(token in table_name for token in ("會計", "分類帳", "科目", "傳票")): + table_scores[table_id] = table_scores.get(table_id, 0) + 1 + + top_tables = sorted(table_scores, key=lambda table_id: (-table_scores[table_id], table_id))[:40] + table_payload = [] + for table_id in top_tables: + table = tables_by_id.get(table_id, {}) + table_fields = [ + { + "column_id": str(field.get("ID", "")).strip(), + "column_name": str(field.get("FieldName", "")).strip(), + "description": str(field.get("Description", "")).strip(), + } + for ref, field in fields_by_ref.items() + if ref.startswith(f"{table_id}.") + ][:80] + table_payload.append( + { + "table_id": table_id, + "table_name": str(table.get("TableName", "")).strip(), + "module_id": str(table.get("ModuleID", "")).strip(), + "fields": table_fields, + } + ) + + return { + "candidate_tables_for_llm_to_choose_from": table_payload, + "direct_alias_hits": sorted(candidate_refs)[:120], + "relationship_edges_hint": "Use relationship_edges.json from repo context conceptually; output explicit join path when a field comes from another table.", + } + + +def build_llm_table_first_classification_prompt( + *, + user_prompt: str, + workbook_path: Path, + primary_sheet: str, + workbook_columns: list[dict[str, Any]], + schema_context: dict[str, Any], +) -> str: + return ( + "You are a Workflow ERP schema analyst. Classify workbook columns for a report harness.\n" + "You MUST reason table-first: first identify the most likely WFERP table group(s) containing the report data, " + "then map each workbook column to table fields or enrichment formulas.\n" + "Do not map a column by header alone. Use table purpose, field meaning, formulas, and relationship paths.\n" + "Output JSON only with keys: candidate_tables, column_mappings, assumptions, confidence.\n" + "candidate_tables: list of {table_id, table_name, reason, confidence}.\n" + "column_mappings: one item per workbook column with keys: excel_column, excel_header, classification " + "(db_source_field|db_derived_field|excel_enrichment_field|unresolved_field), processing_location " + "(formal_db_sql|sqlite_enrichment|excluded_pending_rule), source_expression, fields " + "([{table_id,column_id,business_meaning}]), relationship_path " + "([{from_table,from_columns,to_table,to_columns,reason,confidence}]), lookup_sheet, confidence, reason, risks.\n" + "Rules: DB query may only retrieve raw DB source fields. Excel formulas and lookups must be sqlite_enrichment. " + "If relationship is uncertain, keep the mapping but add a risk instead of hiding the uncertainty.\n" + f"User request: {user_prompt}\n" + f"Workbook: {workbook_path}\n" + f"Primary sheet: {primary_sheet}\n" + f"Workbook columns: {json.dumps(workbook_columns, ensure_ascii=False)}\n" + f"Schema context: {json.dumps(schema_context, ensure_ascii=False)}" + ) + + +def _parse_llm_payload(raw_text: str) -> dict[str, Any]: + payload = json.loads(_strip_fence(raw_text)) + if not isinstance(payload.get("candidate_tables"), list): + raise ValueError("LLM classification missing candidate_tables") + if not isinstance(payload.get("column_mappings"), list): + raise ValueError("LLM classification missing column_mappings") + return payload + + +def _describe_llm_fields( + metadata: SchemaMetadata, + fields: list[dict[str, Any]], + fields_by_ref: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + described = [] + for field in fields: + table_id = str(field.get("table_id", "")).strip() + column_id = str(field.get("column_id", "")).strip() + if not table_id or not column_id: + continue + ref = _field_ref(table_id, column_id) + item = metadata.describe_field( + table_id, + column_id, + business_meaning=str(field.get("business_meaning", "")).strip(), + ) + if ref not in fields_by_ref: + item["metadata_status"] = "warning" + item["business_meaning"] = (item["business_meaning"] + "; LLM field ref not found").strip() + described.append(item) + return described + + +def classify_workbook_with_llm( + workbook_path: str | Path, + *, + source_dir: str | Path, + primary_sheet: str = "", + user_prompt: str = "", + llm_provider: str, + llm_model: str, + timeout_sec: float = 60.0, +) -> dict[str, Any]: + path = Path(workbook_path) + sheet_name, workbook_columns, lookup_sheets = _workbook_columns(path, primary_sheet) + bundle = load_schema_bundle(str(source_dir)) + _tables_by_id, fields_by_ref = _schema_indexes(bundle) + schema_context = _candidate_schema_context(bundle, workbook_columns) + prompt = build_llm_table_first_classification_prompt( + user_prompt=user_prompt, + workbook_path=path, + primary_sheet=sheet_name, + workbook_columns=workbook_columns, + schema_context=schema_context, + ) + raw = call_llm(llm_provider, llm_model, prompt, timeout_sec=timeout_sec) + llm_payload = _parse_llm_payload(raw) + metadata = SchemaMetadata.from_source_dir(source_dir) + + by_key = { + (str(item.get("excel_column", "")).strip(), str(item.get("excel_header", "")).strip()): item + for item in llm_payload["column_mappings"] + if isinstance(item, dict) + } + + columns = [] + for column in workbook_columns: + key = (str(column["excel_column"]), str(column["excel_header"])) + mapping = by_key.get(key) + if not mapping: + mapping = { + "excel_column": column["excel_column"], + "excel_header": column["excel_header"], + "classification": "unresolved_field", + "processing_location": "excluded_pending_rule", + "source_expression": "", + "fields": [], + "relationship_path": [], + "lookup_sheet": column.get("lookup_sheet", ""), + "confidence": "low", + "reason": "LLM did not return a mapping for this workbook column.", + "risks": ["Missing LLM mapping."], + } + field_metadata = _describe_llm_fields( + metadata, + [field for field in mapping.get("fields", []) if isinstance(field, dict)], + fields_by_ref, + ) + lookup_sheet = str(mapping.get("lookup_sheet") or column.get("lookup_sheet") or "") + if lookup_sheet: + lookup_sheets.add(lookup_sheet) + confidence = mapping.get("confidence", "low") + if isinstance(confidence, (int, float)): + confidence = "high" if float(confidence) >= 0.8 else "medium" if float(confidence) >= 0.5 else "low" + columns.append( + { + "excel_column": column["excel_column"], + "excel_header": column["excel_header"], + "classification": str(mapping.get("classification") or "unresolved_field"), + "processing_location": str(mapping.get("processing_location") or "excluded_pending_rule"), + "source_expression": str(mapping.get("source_expression") or ""), + "lookup_sheet": lookup_sheet, + "field_metadata": field_metadata, + "lineage_inputs": field_metadata, + "relationship_path": mapping.get("relationship_path", []), + "schema_candidates": [], + "confidence": str(confidence), + "reason": str(mapping.get("reason") or "LLM table-first classification."), + "risks": [str(risk) for risk in mapping.get("risks", [])], + } + ) + + return { + "workbook_path": str(path), + "primary_sheet": sheet_name, + "resolver_strategy": "llm_table_first_schema_relationship_resolver", + "llm_status": "used", + "llm_provider": llm_provider, + "llm_model": llm_model, + "candidate_tables": llm_payload["candidate_tables"], + "assumptions": llm_payload.get("assumptions", []), + "confidence": llm_payload.get("confidence", 0), + "columns": columns, + "lookup_sheet_inventory": [ + {"sheet_name": name, "role": "lookup_sheet"} for name in sorted(lookup_sheets) + ], + } diff --git a/skill_scripts/metadata_validator.py b/skill_scripts/metadata_validator.py new file mode 100644 index 0000000..016cb57 --- /dev/null +++ b/skill_scripts/metadata_validator.py @@ -0,0 +1,96 @@ +import re +from typing import Any + + +JsonDict = dict[str, Any] + +FROM_JOIN_PATTERN = re.compile( + r"\b(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.){0,2}\[([^\]]+)\](?:\s+(?:AS\s+)?([A-Za-z_][A-Za-z0-9_]*))?", + re.IGNORECASE, +) +FROM_JOIN_UNBRACKETED_PATTERN = re.compile(r"\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE) +COLUMN_PATTERN = re.compile(r"\[([A-Za-z]{2}\d{3})\]") +COLUMN_UNBRACKETED_PATTERN = re.compile(r"\b([A-Za-z]{2}\d{3})\b") +ALIASED_COLUMN_PATTERN = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.\[([A-Za-z]{2}\d{3})\]", re.IGNORECASE) +BRACKETED_QUALIFIED_COLUMN_PATTERN = re.compile( + r"\[([A-Za-z_][A-Za-z0-9_]*)\]\.\[([A-Za-z]{2}\d{3})\]", + re.IGNORECASE, +) + + +def _table_ids(bundle: JsonDict) -> set[str]: + return {str(row.get("TableID", "")).strip().upper() for row in bundle.get("tables", []) if str(row.get("TableID", "")).strip()} + + +def _column_ids(bundle: JsonDict) -> set[str]: + return {str(row.get("ID", "")).strip().upper() for row in bundle.get("fields", []) if str(row.get("ID", "")).strip()} + + +def _columns_by_table(bundle: JsonDict) -> dict[str, set[str]]: + by_table: dict[str, set[str]] = {} + for row in bundle.get("fields", []): + table_id = str(row.get("TableID", "")).strip().upper() + column_id = str(row.get("ID", "")).strip().upper() + if not table_id or not column_id: + continue + by_table.setdefault(table_id, set()).add(column_id) + return by_table + + +def validate_metadata_references(sql: str, bundle: JsonDict) -> tuple[bool, str]: + text = str(sql or "") + known_tables = _table_ids(bundle) + known_columns = _column_ids(bundle) + by_table = _columns_by_table(bundle) + + table_matches = list(FROM_JOIN_PATTERN.finditer(text)) + referenced_tables = {m.group(1).strip().upper() for m in table_matches} + alias_to_table = { + m.group(2).strip().upper(): m.group(1).strip().upper() + for m in table_matches + if m.group(2) + } + + for table_id in referenced_tables: + alias_to_table.setdefault(table_id, table_id) + unbracketed_tables = {m.group(1).strip().upper() for m in FROM_JOIN_UNBRACKETED_PATTERN.finditer(text)} + + if unbracketed_tables: + return False, "TABLE_REFERENCE_FORMAT_INVALID" + + if not referenced_tables: + return False, "NO_TABLE_REFERENCE" + + for table_id in referenced_tables: + if table_id not in known_tables: + return False, "UNKNOWN_TABLE" + + for match in ALIASED_COLUMN_PATTERN.finditer(text): + alias = match.group(1).strip().upper() + column = match.group(2).strip().upper() + if alias not in alias_to_table: + return False, "UNKNOWN_TABLE_ALIAS" + table_id = alias_to_table[alias] + if column not in by_table.get(table_id, set()): + return False, "UNKNOWN_COLUMN_FOR_TABLE" + + for match in BRACKETED_QUALIFIED_COLUMN_PATTERN.finditer(text): + alias = match.group(1).strip().upper() + column = match.group(2).strip().upper() + if alias not in alias_to_table: + return False, "UNKNOWN_TABLE_ALIAS" + table_id = alias_to_table[alias] + if column not in by_table.get(table_id, set()): + return False, "UNKNOWN_COLUMN_FOR_TABLE" + + referenced_columns = {m.group(1).strip().upper() for m in COLUMN_PATTERN.finditer(text)} + referenced_columns.update({m.group(1).strip().upper() for m in COLUMN_UNBRACKETED_PATTERN.finditer(text)}) + for column_id in referenced_columns: + if column_id not in known_columns: + return False, "UNKNOWN_COLUMN" + + if referenced_tables: + if not any(column_id in by_table.get(table_id, set()) for table_id in referenced_tables): + return False, "UNKNOWN_COLUMN_FOR_TABLE" + + return True, "OK" diff --git a/skill_scripts/postgres_readonly_adapter.py b/skill_scripts/postgres_readonly_adapter.py new file mode 100644 index 0000000..9d05099 --- /dev/null +++ b/skill_scripts/postgres_readonly_adapter.py @@ -0,0 +1,93 @@ +import re + + +class PostgresReadonlyAdapterError(ValueError): + pass + + +BLOCKED_KEYWORDS = ( + "INSERT", + "UPDATE", + "DELETE", + "DROP", + "ALTER", + "EXEC", + "MERGE", + "TRUNCATE", + "CREATE", + "GRANT", + "REVOKE", +) + +_WFERP_COLUMN_PATTERN = re.compile(r"\b((?:grand_header|grand_detail|header|detail)\.)?(T[AB]\d{3})\b") + + +def _reject_unsafe_sql(sql: str) -> None: + text = str(sql or "").strip() + upper = text.upper() + if not text: + raise PostgresReadonlyAdapterError("EMPTY_SQL") + if not upper.startswith("SELECT"): + raise PostgresReadonlyAdapterError("NON_SELECT_SQL") + if ";" in text: + raise PostgresReadonlyAdapterError("MULTI_STATEMENT_NOT_ALLOWED") + if "--" in text or "/*" in text or "*/" in text: + raise PostgresReadonlyAdapterError("COMMENTS_NOT_ALLOWED") + if re.search(r"(? str: + match = re.match(r"(?is)^\s*SELECT\s+TOP\s+(\d+)\s+(.*)$", sql) + if not match: + return sql + limit = match.group(1) + body = match.group(2).strip() + return f"SELECT {body} LIMIT {limit}" + + +def _translate_schema_identifiers(sql: str) -> str: + translated = re.sub( + r"\[[A-Za-z_][A-Za-z0-9_]*\]\.\[dbo\]\.\[([A-Za-z_][A-Za-z0-9_]*)\]", + r'dbo."\1"', + sql, + flags=re.IGNORECASE, + ) + translated = re.sub( + r"\[dbo\]\.\[([A-Za-z_][A-Za-z0-9_]*)\]", + r'dbo."\1"', + translated, + flags=re.IGNORECASE, + ) + return re.sub(r"\[([A-Za-z_][A-Za-z0-9_]*)\]", r'"\1"', translated) + + +def _quote_wferp_columns(sql: str) -> str: + def replace(match: re.Match[str]) -> str: + prefix = match.group(1) or "" + column = match.group(2) + start = match.start(2) + end = match.end(2) + if start > 0 and sql[start - 1] == '"': + return match.group(0) + if end < len(sql) and sql[end : end + 1] == '"': + return match.group(0) + return f'{prefix}"{column}"' + + return _WFERP_COLUMN_PATTERN.sub(replace, sql) + + +def translate_sqlserver_select_to_postgres(sql: str) -> str: + _reject_unsafe_sql(sql) + translated = str(sql).strip() + translated = _translate_top(translated) + translated = _translate_schema_identifiers(translated) + translated = _quote_wferp_columns(translated) + translated = re.sub(r"\bISNULL\s*\(", "COALESCE(", translated, flags=re.IGNORECASE) + _reject_unsafe_sql(translated) + return translated diff --git a/skill_scripts/prompt_sql_consistency.py b/skill_scripts/prompt_sql_consistency.py new file mode 100644 index 0000000..efebe8e --- /dev/null +++ b/skill_scripts/prompt_sql_consistency.py @@ -0,0 +1,31 @@ +import re + + +YEAR_PATTERN = re.compile(r"(? tuple[bool, str]: + prompt_text = str(prompt or "") + sql_text = str(sql or "") + sql_upper = sql_text.upper() + + year_match = YEAR_PATTERN.search(prompt_text) + if year_match and year_match.group(1) not in sql_text: + return False, "YEAR_MISMATCH" + + top_match_en = TOP_PATTERN.search(prompt_text) + top_match_zh = re.search(r"前\s*(\d+)\s*筆", prompt_text) + expected_top = None + if top_match_en: + expected_top = top_match_en.group(1) + elif top_match_zh: + expected_top = top_match_zh.group(1) + + if expected_top and f"TOP {expected_top}" not in sql_upper: + return False, "TOP_MISMATCH" + + if "預算" in prompt_text and "ACTM" not in sql_upper and "BUDGET" not in sql_upper: + return False, "DOMAIN_MISMATCH" + + return True, "OK" diff --git a/skill_scripts/relationship_graph.py b/skill_scripts/relationship_graph.py new file mode 100644 index 0000000..629e425 --- /dev/null +++ b/skill_scripts/relationship_graph.py @@ -0,0 +1,63 @@ +from collections import defaultdict +from typing import Any + + +JsonDict = dict[str, Any] + + +def _split_index_columns(index_column_name: str): + return [c.strip() for c in str(index_column_name or "").split("+") if c.strip()] + + +def build_primary_key_map(index_keys: list[JsonDict]) -> dict[str, list[str]]: + primary_key_map = {} + for row in index_keys: + if str(row.get("isPrimaryKey", "")).strip() != "1": + continue + table = str(row.get("TableName", "")).strip() + columns = _split_index_columns(row.get("IndexColumnName", "")) + if table and columns: + primary_key_map[table] = columns + return primary_key_map + + +def _group_tables_by_prefix(pk_map: dict[str, list[str]]) -> dict[str, list[str]]: + grouped = defaultdict(list) + for table in pk_map: + grouped[table[:3]].append(table) + for tables in grouped.values(): + tables.sort() + return dict(grouped) + + +def _infer_edge(parent: str, child: str, parent_pk: list[str], child_pk: list[str]) -> JsonDict: + width = min(len(parent_pk), len(child_pk)) + parent_join_cols = parent_pk[:width] + child_join_cols = child_pk[:width] + cardinality = "1:1" if len(parent_pk) == len(child_pk) and width == len(child_pk) else "1:N" + return { + "from_table": parent, + "to_table": child, + "from_columns": parent_join_cols, + "to_columns": child_join_cols, + "confidence": "medium", + "cardinality": cardinality, + "reason": "heuristic inference from same 3-char prefix and aligned key position pattern", + } + + +def infer_relationships(fields: list[JsonDict], index_keys: list[JsonDict]) -> list[JsonDict]: + del fields + pk_map = build_primary_key_map(index_keys) + by_prefix = _group_tables_by_prefix(pk_map) + + edges = [] + for _, tables in by_prefix.items(): + if len(tables) < 2: + continue + ordered = sorted(tables) + parent = ordered[0] + for child in ordered[1:]: + edge = _infer_edge(parent, child, pk_map[parent], pk_map[child]) + edges.append(edge) + return edges diff --git a/skill_scripts/report_catalog.py b/skill_scripts/report_catalog.py new file mode 100644 index 0000000..5178e01 --- /dev/null +++ b/skill_scripts/report_catalog.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +from typing import Any + + +REPORT_TYPES = [ + {"id": "detail-query", "name": "明細查詢表", "description": "逐筆列出交易與來源欄位,適合查核與追蹤。"}, + {"id": "summary-statistics", "name": "彙總統計表", "description": "依部門、科目或期間彙總金額與數量。"}, + {"id": "trend-analysis", "name": "趨勢分析表", "description": "呈現期間序列變化與異常波動。"}, + {"id": "comparison-analysis", "name": "比較分析表", "description": "比較部門、科目、期間或版本差異。"}, + {"id": "exception-audit", "name": "異常稽核表", "description": "聚焦例外、缺漏、超額與控制風險。"}, + {"id": "management-summary", "name": "管理摘要", "description": "提供主管可快速判讀的摘要、重點與建議。"}, + {"id": "complete-analysis", "name": "完整分析報告", "description": "整合摘要、明細、趨勢、比較、稽核與建議。"}, +] + + +REQUIRED_PROFILE_IDS = [ + "financial-control", + "executive-summary", + "detail-ledger", + "exception-audit", + "operations-review", + "trend-briefing", +] + + +REQUIRED_PROFILE_KEYS = [ + "id", + "label", + "best_for", + "required_sections", + "default_components", + "chart_policy", + "table_policy", + "kpi_policy", + "tone", + "layout_density", + "validator_focus", +] + + +LIST_KEYS = {"best_for", "required_sections", "default_components", "validator_focus"} +DICT_KEYS = {"chart_policy", "table_policy", "kpi_policy"} +DOC_FILES = {"README.md", "design.md"} + + +class ReportDesignCatalog: + def __init__(self, design_dir: Path, profile_ids: list[str], profiles: dict[str, dict[str, Any]]) -> None: + self.design_dir = design_dir + self.profile_ids = list(profile_ids) + self._profiles = profiles + + def get_profile(self, profile_id: str) -> dict[str, Any]: + if profile_id not in self._profiles: + raise ValueError(f"Unknown report design profile: {profile_id}") + return deepcopy(self._profiles[profile_id]) + + def list_profiles(self) -> list[dict[str, Any]]: + return [self.get_profile(profile_id) for profile_id in self.profile_ids] + + def get_scaffold_defaults(self, profile_id: str) -> dict[str, Any]: + profile = self.get_profile(profile_id) + return { + "id": profile["id"], + "label": profile["label"], + "sections": list(profile["required_sections"]), + "components": list(profile["default_components"]), + "chart_policy": deepcopy(profile["chart_policy"]), + "table_policy": deepcopy(profile["table_policy"]), + "kpi_policy": deepcopy(profile["kpi_policy"]), + "tone": profile["tone"], + "layout_density": profile["layout_density"], + "validator_focus": list(profile["validator_focus"]), + } + + +def _default_design_dir() -> Path: + return Path(__file__).resolve().parents[1] / "report_designs" + + +def _parse_scalar(raw: str) -> Any: + value = raw.strip() + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _parse_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + raise ValueError(f"Expected list metadata, got {type(value).__name__}") + + +def _parse_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + raise ValueError(f"Expected object metadata, got {type(value).__name__}") + + +def _parse_profile(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + if not text.startswith("---"): + raise ValueError(f"Design file missing metadata block: {path}") + try: + _, block, body = text.split("---", 2) + except ValueError as exc: + raise ValueError(f"Design file has invalid metadata block: {path}") from exc + + data: dict[str, Any] = {"path": str(path), "body": body.strip()} + for line in block.splitlines(): + if ":" not in line: + continue + key, raw = line.split(":", 1) + key = key.strip() + if not key: + continue + value = _parse_scalar(raw) + if key in LIST_KEYS: + data[key] = _parse_list(value) + elif key in DICT_KEYS: + data[key] = _parse_dict(value) + else: + data[key] = str(value).strip() + + missing = [key for key in REQUIRED_PROFILE_KEYS if not data.get(key)] + if missing: + raise ValueError(f"Design file {path} missing metadata: {', '.join(missing)}") + + for key in LIST_KEYS: + if not isinstance(data[key], list) or not data[key]: + raise ValueError(f"Design file {path} metadata must be a non-empty list: {key}") + for key in DICT_KEYS: + if not isinstance(data[key], dict) or not data[key]: + raise ValueError(f"Design file {path} metadata must be a non-empty object: {key}") + + data["name"] = data["label"] + return data + + +def _load_index(root: Path) -> list[str]: + index_path = root / "index.json" + if not index_path.exists(): + raise ValueError(f"Missing report design index.json: {index_path}") + try: + data = json.loads(index_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid report design index.json: {index_path}") from exc + if data != REQUIRED_PROFILE_IDS: + raise ValueError( + "report_designs/index.json must list exactly: " + ", ".join(REQUIRED_PROFILE_IDS) + ) + return list(data) + + +def load_report_design_catalog(design_dir: str | Path | None = None) -> ReportDesignCatalog: + root = Path(design_dir) if design_dir is not None else _default_design_dir() + profile_ids = _load_index(root) + + missing_paths = [root / f"{profile_id}.md" for profile_id in profile_ids if not (root / f"{profile_id}.md").exists()] + if missing_paths: + names = ", ".join(path.name for path in missing_paths) + raise ValueError(f"Missing report design profile file: {names}") + + profiles: dict[str, dict[str, Any]] = {} + for profile_id in profile_ids: + profile = _parse_profile(root / f"{profile_id}.md") + if profile["id"] != profile_id: + raise ValueError( + f"Design file {(root / f'{profile_id}.md')} declares id {profile['id']}, expected {profile_id}" + ) + profiles[profile_id] = profile + + indexed = set(profile_ids) + for path in root.glob("*.md"): + if path.name in DOC_FILES: + continue + if path.stem not in indexed: + raise ValueError(f"Profile file is not listed in index.json: {path.name}") + profile = _parse_profile(path) + if profile["id"] not in indexed: + raise ValueError(f"Profile ID is not listed in index.json: {profile['id']}") + + return ReportDesignCatalog(root, profile_ids, profiles) + + +def list_report_types() -> list[dict[str, str]]: + return [dict(item) for item in REPORT_TYPES] + + +def list_report_designs(design_dir: str | Path | None = None) -> list[dict[str, Any]]: + return load_report_design_catalog(design_dir).list_profiles() + + +def get_report_design_defaults(profile_id: str, design_dir: str | Path | None = None) -> dict[str, Any]: + return load_report_design_catalog(design_dir).get_scaffold_defaults(profile_id) + + +def build_report_selection_payload() -> dict[str, Any]: + return { + "report_types": list_report_types(), + "report_designs": list_report_designs(), + "default_options": { + "include_chart": True, + "include_table": True, + "include_analysis": True, + "include_recommendations": True, + }, + } diff --git a/skill_scripts/report_gate_checks.py b/skill_scripts/report_gate_checks.py new file mode 100644 index 0000000..a66e5f1 --- /dev/null +++ b/skill_scripts/report_gate_checks.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Any + + +MOJIBAKE_MARKERS = ( + "�", + "雿", + "閮", + "蝞", + "鞎", + "撌", + "銝", + "瘙", + "摰", +) + + +def scan_text_readability(text: str) -> dict[str, Any]: + errors: list[str] = [] + if re.search(r"\?{2,}", text): + errors.append("repeated_question_marks") + if any(marker in text for marker in MOJIBAKE_MARKERS): + errors.append("mojibake_marker") + return {"valid": not errors, "errors": errors} + + +def scan_run_text_artifacts(run_dir: str | Path) -> dict[str, Any]: + run_path = Path(run_dir) + roots = [ + run_path / "checkpoints", + run_path / "plan", + run_path / "source", + run_path / "data", + run_path / "review" / "validators", + ] + candidate_paths = [run_path / "state.json"] + for root in roots: + if root.exists(): + candidate_paths.extend(sorted(root.rglob("*.json"))) + candidate_paths.extend(sorted(root.rglob("*.html"))) + candidate_paths.extend(sorted(root.rglob("*.sql"))) + + errors: list[str] = [] + for path in candidate_paths: + if not path.exists() or not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + relative = path.relative_to(run_path).as_posix() + errors.append(f"{relative}:utf8_decode") + continue + readability = scan_text_readability(text) + if not readability["valid"]: + relative = path.relative_to(run_path).as_posix() + errors.extend(f"{relative}:{error}" for error in readability["errors"]) + return {"valid": not errors, "errors": errors} + + +def load_json_no_bom(path: str | Path) -> dict[str, Any]: + json_path = Path(path) + raw = json_path.read_bytes() + if raw.startswith(b"\xef\xbb\xbf"): + return {"valid": False, "error": "utf8_bom", "path": str(json_path)} + try: + text = raw.decode("utf-8") + payload = json.loads(text) + except UnicodeDecodeError: + return {"valid": False, "error": "utf8_decode", "path": str(json_path)} + except json.JSONDecodeError as exc: + return { + "valid": False, + "error": "invalid_json", + "path": str(json_path), + "message": str(exc), + } + if not isinstance(payload, dict): + return {"valid": False, "error": "json_not_object", "path": str(json_path)} + readability = scan_text_readability(text) + return { + "valid": readability["valid"], + "error": None if readability["valid"] else "text_readability", + "path": str(json_path), + "payload": payload, + "readability_errors": readability["errors"], + } + + +def _relative_or_absolute(base: Path, value: str) -> Path: + candidate = Path(value) + if candidate.is_absolute(): + return candidate + return base / candidate + + +def _forbidden_delivery_patterns(text: str) -> list[str]: + patterns = { + "external_script": r"]*\bsrc\s*=", + "external_stylesheet": r"]*\bhref\s*=", + "network_fetch": r"\bfetch\s*\(", + "websocket": r"websocket", + "http_reference": r"https?://", + "credential_text": r"password|credential|connection_string|Integrated Security|Initial Catalog|User ID|Data Source", + } + return [ + name + for name, pattern in patterns.items() + if re.search(pattern, text, flags=re.IGNORECASE) + ] + + +def _validator_payloads(run_dir: Path) -> tuple[list[dict[str, Any]], list[str]]: + validator_dir = run_dir / "review" / "validators" + if not validator_dir.exists(): + return [], ["missing_validators_dir"] + payloads: list[dict[str, Any]] = [] + errors: list[str] = [] + for path in sorted(validator_dir.glob("*.json")): + result = load_json_no_bom(path) + if not result["valid"]: + errors.append(f"invalid_validator_json:{path.name}:{result['error']}") + continue + payloads.append(result["payload"]) + return payloads, errors + + +def evaluate_delivery_artifacts( + run_dir: str | Path, + *, + required_validators: list[str] | None = None, +) -> dict[str, Any]: + run_path = Path(run_dir) + blocking: list[str] = [] + evidence: list[str] = [] + + manifest_path = run_path / "report" / "delivery" / "manifest.json" + manifest_result = load_json_no_bom(manifest_path) if manifest_path.exists() else { + "valid": False, + "error": "missing_manifest", + } + if not manifest_result["valid"]: + blocking.append(str(manifest_result["error"])) + return {"allowed": False, "blocking_reasons": blocking, "evidence": evidence} + + manifest = manifest_result["payload"] + html_value = str(manifest.get("html_path") or "report/delivery/report.html") + html_path = _relative_or_absolute(run_path, html_value) + if not html_path.exists(): + blocking.append("missing_html") + else: + html_text = html_path.read_text(encoding="utf-8") + html_sha = hashlib.sha256(html_text.encode("utf-8")).hexdigest() + expected_sha = str(manifest.get("sha256") or manifest.get("html_sha256") or "") + if expected_sha and html_sha != expected_sha: + blocking.append("manifest_hash_mismatch") + readability = scan_text_readability(html_text) + if not readability["valid"]: + blocking.extend(f"html_{error}" for error in readability["errors"]) + if "__WFERP_REPORT_PACKAGE__" not in html_text: + blocking.append("missing_embedded_package_marker") + blocking.extend(_forbidden_delivery_patterns(html_text)) + evidence.append(f"html_sha256:{html_sha}") + + if manifest.get("single_file") is not True: + blocking.append("manifest_single_file_not_true") + if manifest.get("network_dependencies", 0) not in {0, "0"}: + blocking.append("manifest_network_dependencies_nonzero") + + validator_payloads, validator_errors = _validator_payloads(run_path) + blocking.extend(validator_errors) + validators_by_role = { + str(payload.get("role")): payload + for payload in validator_payloads + if payload.get("role") + } + for role in required_validators or []: + if role not in validators_by_role: + blocking.append(f"missing_validator:{role}") + continue + fixes = validators_by_role[role].get("required_fixes") + if fixes is None: + fixes = validators_by_role[role].get("requiredFixes") + if fixes: + blocking.append(f"validator_required_fixes:{role}") + + return { + "allowed": not blocking, + "blocking_reasons": blocking, + "evidence": evidence, + "validator_count": len(validator_payloads), + } diff --git a/skill_scripts/report_harness.py b/skill_scripts/report_harness.py new file mode 100644 index 0000000..b56d088 --- /dev/null +++ b/skill_scripts/report_harness.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from skill_scripts.harness_state_machine import GATE_DEFINITIONS, GateBlockedError, assert_can_advance, evaluate_gate +from skill_scripts.report_gate_checks import scan_text_readability +from skill_scripts.validator_contracts import ValidatorContractError, build_final_review_gate +from skill_scripts.report_harness_state import ( + CHECKPOINT_DEFINITIONS, + create_report_run, + load_run_state, + record_checkpoint, + save_run_state, + write_confirmation, +) + + +class ReportHarnessError(RuntimeError): + pass + + +class ReportHarness: + def __init__(self, run_dir: str | Path): + self.run_dir = Path(run_dir) + + @classmethod + def create( + cls, + run_root: str | Path, + *, + run_id: str, + prompt: str, + input_files: list[str] | None = None, + ) -> "ReportHarness": + create_report_run(run_root, run_id=run_id, prompt=prompt, input_files=input_files) + return cls(Path(run_root) / run_id) + + def state(self) -> dict[str, Any]: + return load_run_state(self.run_dir) + + def update_state(self, **updates: Any) -> dict[str, Any]: + state = self.state() + state.update(updates) + return save_run_state(self.run_dir, state) + + def _assert_payload_readable(self, checkpoint: str, payload: dict[str, Any]) -> None: + text = json.dumps(payload, ensure_ascii=False, indent=2) + readability = scan_text_readability(text) + if not readability["valid"]: + errors = ", ".join(readability["errors"]) + raise ReportHarnessError(f"text readability failed for {checkpoint}: {errors}") + + def invalidate_confirmations(self, *checkpoints: str) -> dict[str, Any]: + state = self.state() + confirmations = state.setdefault("user_confirmations", {}) + confirmation_options = state.setdefault("user_confirmation_options", {}) + confirmation_identity = state.setdefault("confirmation_identity", {}) + for checkpoint in checkpoints: + confirmations.pop(checkpoint, None) + confirmation_options.pop(checkpoint, None) + confirmation_identity.pop(checkpoint, None) + self._confirmation_file(checkpoint).unlink(missing_ok=True) + return save_run_state(self.run_dir, state) + + def clear_downstream(self, checkpoints: list[str], **state_resets: Any) -> dict[str, Any]: + state = self.state() + checkpoint_set = set(checkpoints) + state["checkpoints"] = [ + item for item in state.get("checkpoints", []) if item["checkpoint"] not in checkpoint_set + ] + confirmations = state.setdefault("user_confirmations", {}) + confirmation_options = state.setdefault("user_confirmation_options", {}) + confirmation_identity = state.setdefault("confirmation_identity", {}) + for checkpoint in checkpoints: + confirmations.pop(checkpoint, None) + confirmation_options.pop(checkpoint, None) + confirmation_identity.pop(checkpoint, None) + self._confirmation_file(checkpoint).unlink(missing_ok=True) + definition = CHECKPOINT_DEFINITIONS.get(checkpoint) + if definition: + (self.run_dir / "checkpoints" / definition["file"]).unlink(missing_ok=True) + if checkpoint == "visual_design": + (self.run_dir / "visual" / "visual-checkpoint.html").unlink(missing_ok=True) + state.update(state_resets) + return save_run_state(self.run_dir, state) + + def _confirmation_file(self, checkpoint: str) -> Path: + definition = CHECKPOINT_DEFINITIONS.get(checkpoint) + if not definition: + return self.run_dir / "checkpoints" / f"{checkpoint}.confirmation.json" + return self.run_dir / "checkpoints" / definition["file"].replace(".json", ".confirmation.json") + + def _confirmation_identity_payload( + self, + checkpoint: str, + action: str, + selected_options: dict[str, Any] | None, + comment: str = "", + ) -> dict[str, Any]: + checkpoint_path = self.run_dir / "checkpoints" / CHECKPOINT_DEFINITIONS[checkpoint]["file"] + checkpoint_payload = json.loads(checkpoint_path.read_text(encoding="utf-8")) + return { + "action": action, + "run_id": checkpoint_payload["run_id"], + "checkpoint_id": checkpoint_payload.get("checkpoint_id", checkpoint), + "payload_hash": checkpoint_payload["payload_hash"], + "comment": comment, + "selectedOptions": selected_options or {}, + } + + def _is_repair_confirmation( + self, + checkpoint: str, + action: str, + selected_options: dict[str, Any] | None, + ) -> bool: + actions = CHECKPOINT_DEFINITIONS[checkpoint]["actions"] + is_change_action = bool(actions) and action != actions[0] + options = selected_options or {} + return is_change_action or bool(options.get("changeScope")) or bool(options.get("requiresRerender")) + + def record_prompt_repair( + self, + *, + checkpoint: str, + action: str, + comment: str, + selected_options: dict[str, Any], + ) -> dict[str, Any]: + state = self.state() + state["blocking_repair_request"] = { + "checkpoint": checkpoint, + "action": action, + "comment": comment, + "selectedOptions": selected_options, + "changeScope": selected_options.get("changeScope"), + "targetUserStep": selected_options.get("targetUserStep"), + "requiresRerender": bool(selected_options.get("requiresRerender")), + } + state["allowed_next_actions"] = ["repair_current_step"] + for gate in GATE_DEFINITIONS: + state.setdefault("gate_status", {})[gate] = evaluate_gate(state, gate) + state["phase_status"] = { + gate: entry["status"] + for gate, entry in state.get("gate_status", {}).items() + if isinstance(entry, dict) + } + return save_run_state(self.run_dir, state) + + def confirm( + self, + checkpoint: str, + action: str, + *, + selected_options: dict[str, Any] | None = None, + comment: str = "", + ) -> dict[str, Any]: + state = self.state() + if checkpoint not in CHECKPOINT_DEFINITIONS: + raise ReportHarnessError(f"Unknown checkpoint: {checkpoint}") + if action not in CHECKPOINT_DEFINITIONS[checkpoint]["actions"]: + raise ReportHarnessError(f"Invalid checkpoint action for {checkpoint}: {action}") + if checkpoint not in {item["checkpoint"] for item in state.get("checkpoints", [])}: + raise ReportHarnessError(f"{checkpoint} checkpoint has not been created") + write_confirmation( + self.run_dir, + checkpoint, + self._confirmation_identity_payload(checkpoint, action, selected_options, comment), + ) + state = self.state() + state.setdefault("user_confirmations", {})[checkpoint] = action + state.setdefault("user_confirmation_options", {})[checkpoint] = selected_options or {} + state = save_run_state(self.run_dir, state) + if self._is_repair_confirmation(checkpoint, action, selected_options): + state = self.record_prompt_repair( + checkpoint=checkpoint, + action=action, + comment=comment, + selected_options=selected_options or {}, + ) + return state + + def write_excel_confirmation(self, payload: dict[str, Any]) -> dict[str, Any]: + return record_checkpoint(self.run_dir, "excel_confirmation", payload) + + def write_sql_review(self, sql: str, validation: dict[str, Any] | None = None) -> dict[str, Any]: + self.clear_downstream( + [ + "data_preview", + "raw_data_preview", + "enriched_data_preview", + "sqlite_retention", + "report_selection", + "design_brief", + "visual_design", + "report_draft", + "final_review", + ], + raw_data_preview=None, + enriched_data_preview=None, + sqlite_retention=None, + execution_result_summary=None, + report_type=None, + report_design=None, + report_design_brief=None, + visual_design_checkpoint=None, + report_options={}, + validator_results=[], + ) + self.invalidate_confirmations("sql_review") + sql_path = self.run_dir / "sql" / "query.sql" + sql_path.parent.mkdir(parents=True, exist_ok=True) + sql_path.write_text(sql, encoding="utf-8") + state = self.state() + artifact_status = state.setdefault("artifact_status", {}) + artifact_status["sql/query.sql"] = "complete" + self.update_state( + sql_candidate=sql, + sql_validation=validation or {"status": "pending_user_confirmation"}, + artifact_status=artifact_status, + ) + return record_checkpoint(self.run_dir, "sql_review", {"sql": sql, "validation": validation or {}}) + + def write_field_formula_classification(self, payload: dict[str, Any]) -> dict[str, Any]: + self._assert_payload_readable("field_formula_classification", payload) + self.update_state(column_classification=payload) + self.invalidate_confirmations( + "sql_review", + "raw_data_preview", + "enriched_data_preview", + "report_selection", + ) + return record_checkpoint(self.run_dir, "field_formula_classification", payload) + + def write_data_preview(self, payload: dict[str, Any]) -> dict[str, Any]: + state = self.state() + sql_confirm_action = CHECKPOINT_DEFINITIONS["sql_review"]["actions"][0] + if state.get("user_confirmations", {}).get("sql_review") != sql_confirm_action: + raise ReportHarnessError("SQL must be confirmed before writing data preview") + identity = state.get("confirmation_identity", {}).get("sql_review") + gate_confirmation = ( + state.get("gate_status", {}) + .get("phase_4_sql_review", {}) + .get("confirmation", {}) + ) + if not isinstance(identity, dict) or identity.get("payload_hash") != gate_confirmation.get("payload_hash"): + raise ReportHarnessError("confirmation_identity does not match current sql_review checkpoint") + try: + assert_can_advance(state, "phase_4_sql_review") + except GateBlockedError as exc: + raise ReportHarnessError(f"confirmation_identity gate blocked: {exc}") from exc + self.clear_downstream( + ["report_selection", "design_brief", "visual_design", "report_draft", "final_review"], + report_type=None, + report_design=None, + report_design_brief=None, + visual_design_checkpoint=None, + report_options={}, + validator_results=[], + ) + self.update_state(execution_result_summary=payload) + return record_checkpoint(self.run_dir, "data_preview", payload) + + def write_raw_data_preview(self, payload: dict[str, Any]) -> dict[str, Any]: + self.clear_downstream( + [ + "enriched_data_preview", + "sqlite_retention", + "report_selection", + "design_brief", + "visual_design", + "report_draft", + "final_review", + ], + enriched_data_preview=None, + sqlite_retention=None, + execution_result_summary=None, + report_type=None, + report_design=None, + report_design_brief=None, + visual_design_checkpoint=None, + report_options={}, + validator_results=[], + ) + self.update_state(raw_data_preview=payload) + return record_checkpoint(self.run_dir, "raw_data_preview", payload) + + def write_enriched_data_preview(self, payload: dict[str, Any]) -> dict[str, Any]: + self.clear_downstream( + [ + "sqlite_retention", + "report_selection", + "design_brief", + "visual_design", + "report_draft", + "final_review", + ], + sqlite_retention=None, + report_type=None, + report_design=None, + report_design_brief=None, + visual_design_checkpoint=None, + report_options={}, + validator_results=[], + ) + self.update_state(enriched_data_preview=payload, execution_result_summary=payload) + return record_checkpoint(self.run_dir, "enriched_data_preview", payload) + + def write_sqlite_retention(self, payload: dict[str, Any]) -> dict[str, Any]: + self.update_state(sqlite_retention=payload) + return record_checkpoint(self.run_dir, "sqlite_retention", payload) + + def write_report_selection(self, payload: dict[str, Any]) -> dict[str, Any]: + self.clear_downstream( + ["design_brief", "visual_design", "report_draft", "final_review"], + report_design_brief=None, + visual_design_checkpoint=None, + validator_results=[], + ) + self.invalidate_confirmations("report_selection") + updates: dict[str, Any] = {"report_options": payload.get("selected_options", payload)} + if payload.get("selected_report_type"): + updates["report_type"] = payload["selected_report_type"] + if payload.get("selected_report_design"): + updates["report_design"] = payload["selected_report_design"] + self.update_state(**updates) + return record_checkpoint(self.run_dir, "report_selection", payload) + + def write_design_brief(self, payload: dict[str, Any]) -> dict[str, Any]: + self.clear_downstream( + ["visual_design", "report_draft", "final_review"], + validator_results=[], + visual_design_checkpoint=None, + ) + self.invalidate_confirmations("design_brief") + self.update_state(report_design_brief=payload) + return record_checkpoint(self.run_dir, "design_brief", payload) + + def write_visual_design(self, payload: dict[str, Any]) -> dict[str, Any]: + if self.state().get("user_confirmations", {}).get("design_brief") != "確認設計": + raise ReportHarnessError("Design brief must be confirmed before visual checkpoint") + self.clear_downstream( + ["report_draft", "final_review"], + validator_results=[], + ) + self.invalidate_confirmations("visual_design") + self.update_state(visual_design_checkpoint=payload) + return record_checkpoint(self.run_dir, "visual_design", payload) + + def write_report_draft(self, payload: dict[str, Any]) -> dict[str, Any]: + confirmations = self.state().get("user_confirmations", {}) + if confirmations.get("report_selection") != "產生報告": + raise ReportHarnessError("Report selection must be confirmed before writing draft") + if confirmations.get("design_brief") != "確認設計": + raise ReportHarnessError("Design brief must be confirmed before writing draft") + if confirmations.get("visual_design") != "確認視覺設計": + raise ReportHarnessError("Visual design must be confirmed before writing draft") + self.clear_downstream(["final_review"], validator_results=[]) + self.invalidate_confirmations("report_draft") + return record_checkpoint(self.run_dir, "report_draft", payload) + + def write_final_review(self, payload: dict[str, Any]) -> dict[str, Any]: + if self.state().get("user_confirmations", {}).get("report_draft") != "接受": + raise ReportHarnessError("Draft must be accepted before final review") + self.update_state( + validator_results=payload.get("validator_results", []), + accepted_residual_risks=[], + ) + return record_checkpoint(self.run_dir, "final_review", payload) + + def _final_confirmation_selected_options(self, state: dict[str, Any]) -> dict[str, Any]: + options = state.get("user_confirmation_options", {}).get("final_review") + if isinstance(options, dict) and options: + return options + + definition = CHECKPOINT_DEFINITIONS["final_review"] + path = self.run_dir / "checkpoints" / definition["file"].replace(".json", ".confirmation.json") + if not path.exists(): + return {} + try: + confirmation = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + selected_options = confirmation.get("selectedOptions") + return selected_options if isinstance(selected_options, dict) else {} + + def _final_confirmation_accepted_residual_risks(self, state: dict[str, Any]) -> list[str]: + if state.get("user_confirmations", {}).get("final_review") != "完成": + return [] + accepted = self._final_confirmation_selected_options(state).get("acceptedResidualRisks") + if not isinstance(accepted, list) or not all(isinstance(item, str) for item in accepted): + return [] + return accepted + + def can_deliver(self) -> dict[str, Any]: + state = self.state() + accepted_residual_risks = self._final_confirmation_accepted_residual_risks(state) + explicit_user_acceptance = bool(accepted_residual_risks) + try: + return build_final_review_gate( + state.get("validator_results", []), + explicit_user_acceptance=explicit_user_acceptance, + accepted_residual_risks=accepted_residual_risks, + ) + except ValidatorContractError as exc: + return { + "allowed": False, + "blocking_validators": ["validator_contract"], + "accepted_residual_risks": accepted_residual_risks, + "error": str(exc), + } + + def append_repair_log( + self, + *, + validator: str, + failure: str, + scope: str, + minimal_vertical_slice: str, + files_changed: list[str], + validation_rerun: str, + residual_risk: str, + ) -> Path: + path = self.run_dir / "review" / "repair-log.md" + path.parent.mkdir(parents=True, exist_ok=True) + changed = "\n".join(f"- {item}" for item in files_changed) if files_changed else "- None" + entry = ( + f"## {validator}\n\n" + f"Failure:\n{failure}\n\n" + f"Scope:\n{scope}\n\n" + f"Minimal vertical slice:\n{minimal_vertical_slice}\n\n" + f"Files changed:\n{changed}\n\n" + f"Validation rerun:\n{validation_rerun}\n\n" + f"Residual risk:\n{residual_risk}\n\n" + ) + with path.open("a", encoding="utf-8") as handle: + handle.write(entry) + return path diff --git a/skill_scripts/report_harness_state.py b/skill_scripts/report_harness_state.py new file mode 100644 index 0000000..58c097a --- /dev/null +++ b/skill_scripts/report_harness_state.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +from typing import Any +import uuid + +from skill_scripts.harness_state_machine import GATE_DEFINITIONS, evaluate_gate, initialize_state_machine +from skill_scripts.report_gate_checks import scan_text_readability + + +CHECKPOINT_DEFINITIONS: dict[str, dict[str, Any]] = { + "excel_confirmation": { + "index": 1, + "file": "01_excel_confirmation.json", + "title": "確認欄位與公式", + "actions": ["確認欄位與公式", "要求修正"], + }, + "sql_review": { + "index": 2, + "file": "02_sql_review.json", + "title": "SQL 查詢確認", + "actions": ["同意查詢", "調整需求"], + }, + "field_formula_classification": { + "index": 1.5, + "file": "01b_field_formula_classification.json", + "title": "欄位分類與公式確認", + "actions": ["確認欄位分類", "要求修正"], + }, + "data_preview": { + "index": 3, + "file": "03_data_preview.json", + "title": "資料預覽確認", + "actions": ["資料正確", "確認資料", "重新查詢"], + }, + "raw_data_preview": { + "index": 3.1, + "file": "03a_raw_data_preview.json", + "title": "DB 原始資料確認", + "actions": ["原始資料正確", "重新查詢"], + }, + "enriched_data_preview": { + "index": 3.2, + "file": "03b_enriched_data_preview.json", + "title": "SQLite 補欄資料確認", + "actions": ["補欄資料正確", "修正補欄規則"], + }, + "report_selection": { + "index": 4, + "file": "04_report_selection.json", + "title": "報表格式選擇", + "actions": ["產生報告", "修改格式"], + }, + "design_brief": { + "index": 4.1, + "file": "04a_design_brief.json", + "title": "動態設計確認", + "actions": ["確認設計", "調整設計"], + }, + "visual_design": { + "index": 4.2, + "file": "04b_visual_design.json", + "title": "視覺設計確認", + "actions": ["確認視覺設計", "調整視覺設計"], + }, + "report_draft": { + "index": 5, + "file": "05_report_draft.json", + "title": "報告初稿審核", + "actions": ["接受", "修正報告"], + }, + "final_review": { + "index": 6, + "file": "06_final_review.json", + "title": "最終報告審核", + "actions": ["完成", "回到初稿"], + }, + "sqlite_retention": { + "index": 6.5, + "file": "06b_sqlite_retention.json", + "title": "SQLite 暫存資料保留確認", + "actions": ["保留本地資料", "刪除本次資料", "匯出封存後刪除"], + }, +} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _state_path(run_dir: str | Path) -> Path: + return Path(run_dir) / "state.json" + + +def _write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _assert_readable_artifact_text(relative_path: str, text: str) -> None: + readability = scan_text_readability(text) + if not readability["valid"]: + errors = ", ".join(readability["errors"]) + raise ValueError(f"text readability failed for {relative_path}: {errors}") + + +def _payload_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + return hashlib.sha256(encoded).hexdigest() + + +def _checkpoint_artifact_path(checkpoint: str) -> str: + return f"checkpoints/{CHECKPOINT_DEFINITIONS[checkpoint]['file']}" + + +def _confirmation_path(run_dir: str | Path, checkpoint: str) -> Path: + definition = CHECKPOINT_DEFINITIONS[checkpoint] + return Path(run_dir) / "checkpoints" / definition["file"].replace(".json", ".confirmation.json") + + +def _gate_for_confirmation_checkpoint(checkpoint: str) -> str | None: + for gate, definition in GATE_DEFINITIONS.items(): + if definition["confirmation_checkpoint"] == checkpoint: + return gate + return None + + +def _refresh_gate_status(state: dict[str, Any], *gates: str) -> dict[str, Any]: + gate_status = state.setdefault("gate_status", {}) + for gate in gates: + gate_status[gate] = evaluate_gate(state, gate) + state["phase_status"] = { + gate: entry["status"] for gate, entry in gate_status.items() if isinstance(entry, dict) + } + return state + + +def create_report_run( + run_root: str | Path, + *, + run_id: str, + prompt: str, + input_files: list[str] | None = None, +) -> dict[str, Any]: + run_dir = Path(run_root) / run_id + if _state_path(run_dir).exists(): + raise FileExistsError(f"Report run already exists: {run_id}") + for child in [ + "inputs", + "sql", + "data", + "checkpoints", + "reports", + "source", + "plan", + "audit", + "review", + "report/payload", + ]: + (run_dir / child).mkdir(parents=True, exist_ok=True) + + state = { + "run_id": run_id, + "prompt": prompt, + "input_files": input_files or [], + "schema_snapshot": None, + "excel_requirement": None, + "sql_candidate": None, + "sql_validation": None, + "column_classification": None, + "sqlite_manifest": None, + "sqlite_manifest_path": None, + "raw_data_preview": None, + "enriched_data_preview": None, + "sqlite_retention": None, + "execution_result_summary": None, + "report_type": None, + "report_design": None, + "report_design_brief": None, + "visual_design_checkpoint": None, + "report_options": {}, + "validator_results": [], + "user_confirmations": {}, + "checkpoints": [], + "created_at": _now(), + "updated_at": _now(), + } + state = initialize_state_machine(state) + _write_json(_state_path(run_dir), state) + return state + + +def load_run_state(run_dir: str | Path) -> dict[str, Any]: + return json.loads(_state_path(run_dir).read_text(encoding="utf-8")) + + +def save_run_state(run_dir: str | Path, state: dict[str, Any]) -> dict[str, Any]: + state = deepcopy(state) + state["updated_at"] = _now() + _write_json(_state_path(run_dir), state) + return state + + +def record_checkpoint(run_dir: str | Path, checkpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + if checkpoint not in CHECKPOINT_DEFINITIONS: + raise ValueError(f"Unknown checkpoint: {checkpoint}") + definition = CHECKPOINT_DEFINITIONS[checkpoint] + run_path = Path(run_dir) + state = initialize_state_machine(load_run_state(run_path)) + payload_hash = _payload_hash(payload) + checkpoint_payload = { + "checkpoint": checkpoint, + "checkpoint_id": checkpoint, + "run_id": state["run_id"], + "title": definition["title"], + "actions": definition["actions"], + "payload": payload, + "payload_hash": payload_hash, + "created_at": _now(), + } + checkpoint_relative_path = f"checkpoints/{definition['file']}" + checkpoint_text = json.dumps(checkpoint_payload, ensure_ascii=False, indent=2) + _assert_readable_artifact_text(checkpoint_relative_path, checkpoint_text) + (run_path / checkpoint_relative_path).parent.mkdir(parents=True, exist_ok=True) + (run_path / checkpoint_relative_path).write_text(checkpoint_text, encoding="utf-8") + + entry = { + "checkpoint": checkpoint, + "checkpoint_id": checkpoint, + "file": checkpoint_relative_path, + "payload_hash": payload_hash, + "created_at": checkpoint_payload["created_at"], + } + state["checkpoints"] = [item for item in state["checkpoints"] if item["checkpoint"] != checkpoint] + state["checkpoints"].append(entry) + state.setdefault("artifact_status", {})[_checkpoint_artifact_path(checkpoint)] = "complete" + state.setdefault("confirmation_identity", {}).pop(checkpoint, None) + state.setdefault("user_confirmations", {}).pop(checkpoint, None) + state.setdefault("user_confirmation_options", {}).pop(checkpoint, None) + _confirmation_path(run_path, checkpoint).unlink(missing_ok=True) + gate = _gate_for_confirmation_checkpoint(checkpoint) + if gate: + gate_entry = evaluate_gate(state, gate) + gate_entry["confirmation"]["payload_hash"] = payload_hash + state.setdefault("gate_status", {})[gate] = gate_entry + state["phase_status"][gate] = gate_entry["status"] + save_run_state(run_path, state) + return checkpoint_payload + + +def append_audit_event(run_dir: str | Path, event: str, payload: dict[str, Any]) -> dict[str, Any]: + entry = {"event": event, "payload": payload, "created_at": _now()} + path = Path(run_dir) / "audit" / "events.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + return entry + + +def write_confirmation(run_dir: str | Path, checkpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + if checkpoint not in CHECKPOINT_DEFINITIONS: + raise ValueError(f"Unknown checkpoint: {checkpoint}") + run_path = Path(run_dir) + state = initialize_state_machine(load_run_state(run_path)) + checkpoint_path = run_path / "checkpoints" / CHECKPOINT_DEFINITIONS[checkpoint]["file"] + if not checkpoint_path.exists(): + raise ValueError(f"Checkpoint payload does not exist: {checkpoint}") + checkpoint_payload = json.loads(checkpoint_path.read_text(encoding="utf-8")) + expected_hash = str(checkpoint_payload.get("payload_hash") or "") + run_id = payload.get("run_id") + checkpoint_id = payload.get("checkpoint_id") or payload.get("checkpointId") + payload_hash = payload.get("payload_hash") + if run_id != state.get("run_id") or checkpoint_id != checkpoint or payload_hash != expected_hash: + raise ValueError("confirmation identity does not match current checkpoint") + + confirmation = { + "run_id": run_id, + "checkpoint": checkpoint, + "checkpoint_id": checkpoint, + "action": payload["action"], + "comment": payload.get("comment", ""), + "selectedOptions": payload.get("selectedOptions", {}), + "payload_hash": payload_hash, + "confirmation_id": payload.get("confirmation_id") or str(uuid.uuid4()), + "created_at": _now(), + } + _write_json(_confirmation_path(run_path, checkpoint), confirmation) + state.setdefault("confirmation_identity", {})[checkpoint] = { + "status": "matched", + "checkpoint_id": checkpoint, + "payload_hash": payload_hash, + "confirmation_id": confirmation["confirmation_id"], + "action": confirmation["action"], + "created_at": confirmation["created_at"], + "confirmed_at": confirmation["created_at"], + } + gate = _gate_for_confirmation_checkpoint(checkpoint) + if gate: + _refresh_gate_status(state, gate) + save_run_state(run_path, state) + return confirmation diff --git a/skill_scripts/report_package.py b/skill_scripts/report_package.py new file mode 100644 index 0000000..311a6c2 --- /dev/null +++ b/skill_scripts/report_package.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +from pathlib import Path +from typing import Any, Mapping + +from skill_scripts.report_harness import ReportHarness +from skill_scripts.report_harness_state import load_run_state +from skill_scripts.sql2000_guard import validate_sql + + +SCHEMA_VERSION = "wferp.report-package.v1" +MAX_EMBEDDED_ROWS = 5000 +SECRET_KEY_PARTS = ( + "password", + "pwd", + "credential", + "connection_string", + "db_connection_string", +) + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha256_json(value: Any) -> str: + return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _without_package_hash(package: Mapping[str, Any]) -> dict[str, Any]: + candidate = deepcopy(dict(package)) + hashes = candidate.get("hashes") + if isinstance(hashes, dict): + hashes.pop("package_sha256", None) + return candidate + + +def _secret_key_paths(value: Any, path: tuple[str, ...] = ()) -> list[str]: + paths: list[str] = [] + if isinstance(value, Mapping): + for key, child in value.items(): + key_text = str(key) + child_path = (*path, key_text) + lowered = key_text.lower() + if any(part in lowered for part in SECRET_KEY_PARTS): + paths.append(".".join(child_path)) + paths.extend(_secret_key_paths(child, child_path)) + elif isinstance(value, list): + for index, child in enumerate(value): + paths.extend(_secret_key_paths(child, (*path, str(index)))) + return paths + + +def _columns_from_rows(rows: list[Any]) -> list[str]: + columns: list[str] = [] + for row in rows: + if not isinstance(row, Mapping): + continue + for key in row: + key_text = str(key) + if key_text not in columns: + columns.append(key_text) + return columns + + +def _data_profile(execution_result: Mapping[str, Any]) -> dict[str, Any]: + raw_rows = execution_result.get("rows", []) + rows = raw_rows if isinstance(raw_rows, list) else [] + raw_columns = execution_result.get("columns") + columns = list(raw_columns) if isinstance(raw_columns, list) else _columns_from_rows(rows) + row_count_value = execution_result.get("row_count", len(rows)) + row_count = row_count_value if isinstance(row_count_value, int) and row_count_value >= 0 else len(rows) + embedded_count = min(len(rows), MAX_EMBEDDED_ROWS) + embedded_mode = "full_rows" if row_count <= MAX_EMBEDDED_ROWS else "summary_plus_preview" + return { + "row_count": row_count, + "columns": columns, + "embedded_mode": embedded_mode, + "embedded_rows": embedded_count, + "full_rows_in_evidence_packet": row_count > MAX_EMBEDDED_ROWS, + } + + +def build_report_package(run_dir: str | Path) -> dict[str, Any]: + run_path = Path(run_dir) + state = load_run_state(run_path) + harness = ReportHarness(run_path) + delivery_gate = harness.can_deliver() + if state.get("user_confirmations", {}).get("final_review") != "完成": + delivery_gate = { + **delivery_gate, + "allowed": False, + "blocking_validators": [ + *delivery_gate.get("blocking_validators", []), + "final_review_confirmation", + ], + } + + sql_text = str(state.get("sql_candidate") or "") + sql_valid, sql_reason = validate_sql(sql_text) + sql_validation = dict(state.get("sql_validation") or {}) + sql_validation.setdefault("readonly", sql_valid) + sql_validation.setdefault("readonly_reason", sql_reason) + + execution_result = state.get("execution_result_summary") + if not isinstance(execution_result, Mapping): + execution_result = {} + raw_rows = execution_result.get("rows", []) + rows = raw_rows if isinstance(raw_rows, list) else [] + data_profile = _data_profile(execution_result) + embedded_rows = rows[:MAX_EMBEDDED_ROWS] + validator_results = state.get("validator_results", []) + if not isinstance(validator_results, list): + validator_results = [] + + report_options = state.get("report_options") + report_design = state.get("report_design") + catalog_guardrail = ( + report_design if isinstance(report_design, str) and report_design else "financial-control" + ) + package: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": state.get("run_id"), + "package_id": state.get("run_id"), + "prompt": state.get("prompt"), + "catalog_guardrail": catalog_guardrail, + "report_type": state.get("report_type"), + "report_options": report_options if isinstance(report_options, dict) else {}, + "sql": { + "text": sql_text, + "validation": sql_validation, + }, + "data_profile": data_profile, + "datasets": { + "columns": data_profile["columns"], + "embedded_rows": embedded_rows, + }, + "aggregates": execution_result.get("aggregates", {}), + "excluded_rows": execution_result.get("excluded_rows", []), + "validator_summary": deepcopy(validator_results), + "accepted_residual_risks": delivery_gate.get("accepted_residual_risks", []), + "delivery_gate": delivery_gate, + "evidence_index": [ + {"id": "sql", "path": "evidence/query.sql"}, + {"id": "execution_result", "path": "evidence/execution-result.json"}, + {"id": "validators", "path": "evidence/validator-results.json"}, + ], + "security": { + "source_secret_key_paths": _secret_key_paths(state), + }, + "hashes": { + "sql_sha256": _sha256_text(sql_text), + }, + } + package["hashes"]["package_sha256"] = _sha256_json(_without_package_hash(package)) + return package + + +def validate_report_package(package: dict[str, Any]) -> dict[str, Any]: + errors: list[str] = [] + + security = package.get("security") + source_secret_key_paths = ( + security.get("source_secret_key_paths") if isinstance(security, Mapping) else None + ) + if _secret_key_paths(package) or source_secret_key_paths: + errors.append("credentials") + + delivery_gate = package.get("delivery_gate") + if not isinstance(delivery_gate, Mapping) or delivery_gate.get("allowed") is not True: + errors.append("delivery_gate") + + sql = package.get("sql") + sql_text = sql.get("text") if isinstance(sql, Mapping) else None + readonly, _reason = validate_sql(str(sql_text or "")) + if not readonly: + errors.append("sql_readonly") + + data_profile = package.get("data_profile") + datasets = package.get("datasets") + columns = data_profile.get("columns") if isinstance(data_profile, Mapping) else None + dataset_columns = datasets.get("columns") if isinstance(datasets, Mapping) else None + embedded_rows = datasets.get("embedded_rows") if isinstance(datasets, Mapping) else None + if not isinstance(columns, list) or not columns or columns != dataset_columns: + errors.append("columns") + elif isinstance(embedded_rows, list): + row_keys = set() + for row in embedded_rows: + if isinstance(row, Mapping): + row_keys.update(str(key) for key in row) + if row_keys and not row_keys.issubset(set(str(column) for column in columns)): + errors.append("columns") + else: + errors.append("columns") + + hashes = package.get("hashes") + expected_hash = hashes.get("package_sha256") if isinstance(hashes, Mapping) else None + if not isinstance(expected_hash, str) or _sha256_json(_without_package_hash(package)) != expected_hash: + errors.append("package_hash") + + return {"valid": not errors, "errors": errors} diff --git a/skill_scripts/report_scaffold.py b/skill_scripts/report_scaffold.py new file mode 100644 index 0000000..8849d63 --- /dev/null +++ b/skill_scripts/report_scaffold.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import json +import re +import shutil +from pathlib import Path +from typing import Any + + +def _slug_to_component_name(slug: str, index: int) -> str: + words = re.findall(r"[A-Za-z0-9]+", slug) + if not words or slug == f"section-{index:02d}": + words = ["section"] + base = "".join(word[:1].upper() + word[1:] for word in words) + return f"{base}{index:02d}Section" + + +def _safe_slug(section: str, index: int) -> str: + slug = section.strip().lower() + slug = re.sub(r"[^a-z0-9]+", "-", slug) + slug = slug.strip("-") + return slug or f"section-{index:02d}" + + +def _protected_outputs(run_dir: Path) -> list[Path]: + report_dir = run_dir / "report" + candidates = [ + report_dir / "Report.tsx", + report_dir / "payload" / "approved-query-result.json", + report_dir / "payload" / "report-context.json", + ] + candidates.extend(sorted((report_dir / "sections").glob("*.tsx"))) + return [path for path in candidates if path.exists()] + + +def _ensure_can_write(run_dir: Path, force: bool) -> None: + if force: + return + existing = _protected_outputs(run_dir) + if existing: + relative = existing[0].relative_to(run_dir) + raise FileExistsError( + f"Refusing to scaffold into existing report workspace without force: {relative}" + ) + + +def _copy_template(template_dir: Path, run_dir: Path) -> None: + if not template_dir.exists() or not template_dir.is_dir(): + raise FileNotFoundError(f"Report scaffold template not found: {template_dir}") + run_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(template_dir, run_dir, dirs_exist_ok=True) + + +def _write_payload(run_dir: Path, payload: dict[str, Any]) -> None: + payload_dir = run_dir / "report" / "payload" + payload_dir.mkdir(parents=True, exist_ok=True) + approved_query_result = payload.get("approved_query_result", payload) + (payload_dir / "approved-query-result.json").write_text( + json.dumps(approved_query_result, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (payload_dir / "report-context.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _create_section_files(run_dir: Path, sections: list[str]) -> list[dict[str, str]]: + sections_dir = run_dir / "report" / "sections" + sections_dir.mkdir(parents=True, exist_ok=True) + generated: list[dict[str, str]] = [] + for index, section in enumerate(sections, start=1): + slug = _safe_slug(section, index) + component_name = _slug_to_component_name(slug, index) + filename = f"{index:02d}-{slug}.tsx" + (sections_dir / filename).write_text( + "\n".join( + [ + "import React from \"react\";", + "", + f"export function {component_name}() {{", + " return (", + f"
    ", + f"

    {section}

    ", + "
    ", + " );", + "}", + "", + ] + ), + encoding="utf-8", + ) + generated.append( + { + "component": component_name, + "filename": filename, + "import_path": f"./sections/{filename.removesuffix('.tsx')}", + } + ) + return generated + + +def _write_report_entry(run_dir: Path, generated_sections: list[dict[str, str]]) -> None: + report_path = run_dir / "report" / "Report.tsx" + imports = "\n".join( + f'import {{ {section["component"]} }} from "{section["import_path"]}";' + for section in generated_sections + ) + rendered = "\n".join( + f" <{section['component']} key=\"{section['component']}\" />" + for section in generated_sections + ) + report_path.write_text( + "\n".join( + [ + "import React from \"react\";", + 'import approvedQueryResult from "./payload/approved-query-result.json";', + imports, + "", + "export default function Report() {", + " return (", + "
    ", + "

    WFERP 報告

    ", + "
    ",
    +                "        {JSON.stringify(approvedQueryResult, null, 2)}",
    +                "      
    ", + rendered, + "
    ", + " );", + "}", + "", + ] + ), + encoding="utf-8", + ) + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Payload JSON is not loadable: {path}") from exc + + +def _section_export(section_path: Path) -> str: + section_text = section_path.read_text(encoding="utf-8") + exports = re.findall(r"export\s+function\s+([A-Za-z][A-Za-z0-9]*)\s*\(", section_text) + if len(exports) != 1: + raise ValueError(f"Section must export one React component: {section_path}") + return exports[0] + + +def _section_path(run_dir: Path, section_id: str) -> Path: + if not re.fullmatch(r"\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*", section_id): + raise ValueError("section_id must look like NN-slug, for example 01-executive-summary") + return run_dir / "report" / "sections" / f"{section_id}.tsx" + + +def _validate_component_name(component_name: str) -> None: + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", component_name): + raise ValueError("component_name must be a PascalCase React component identifier") + + +def _validate_section_code_safety(code: str, *, component_name: str, section_id: str) -> None: + _validate_component_name(component_name) + if not code.strip(): + raise ValueError("Section code is empty") + + export_pattern = rf"export\s+function\s+{re.escape(component_name)}\s*\(" + if not re.search(export_pattern, code): + raise ValueError(f"Section must export function {component_name}") + + exports = re.findall(r"export\s+function\s+([A-Za-z][A-Za-z0-9]*)\s*\(", code) + if exports != [component_name]: + raise ValueError("Section must export exactly the requested component") + + import_sources = re.findall(r"import\s+(?:[^;]*?\s+from\s+)?[\"']([^\"']+)[\"']", code) + for source in import_sources: + if source == "react" or source.startswith("."): + continue + raise ValueError(f"Section import is not allowed: {source}") + + banned_patterns = [ + (r"\bfetch\s*\(", "network access is not allowed"), + (r"\bXMLHttpRequest\b", "network access is not allowed"), + (r"\bWebSocket\b", "network access is not allowed"), + (r"\bEventSource\b", "network access is not allowed"), + (r"\baxios\b", "network access is not allowed"), + (r"\bprocess\s*\.\s*env\b", "environment access is not allowed"), + (r"\bimport\s*\(", "dynamic import is not allowed"), + (r"\brequire\s*\(", "CommonJS require is not allowed"), + (r"\beval\s*\(", "dynamic code execution is not allowed"), + (r"\bFunction\s*\(", "dynamic code execution is not allowed"), + (r"\bdocument\s*\.\s*cookie\b", "credential access is not allowed"), + (r"\blocalStorage\b", "browser storage side effects are not allowed"), + (r"\bsessionStorage\b", "browser storage side effects are not allowed"), + (r"\bindexedDB\b", "browser storage side effects are not allowed"), + (r"https?://", "external URL is not allowed"), + ] + for pattern, message in banned_patterns: + if re.search(pattern, code): + raise ValueError(f"Unsafe report section: {message}") + + if "data-refs" not in code and "dataRefs" not in code: + raise ValueError("Section must declare data_refs using data-refs or dataRefs") + + slug = section_id[3:] + if f'data-section="{slug}"' not in code and f"data-section='{slug}'" not in code: + raise ValueError(f'Section must render data-section="{slug}"') + + +def write_generated_report_section( + *, + run_dir: Path, + section_id: str, + component_name: str, + code: str, + mode: str = "generate", +) -> dict[str, Any]: + if mode not in {"generate", "repair"}: + raise ValueError("mode must be generate or repair") + section_path = _section_path(run_dir, section_id) + if not section_path.exists(): + raise ValueError(f"Section does not exist in scaffold: {section_path}") + _validate_section_code_safety(code, component_name=component_name, section_id=section_id) + section_path.write_text(code.strip() + "\n", encoding="utf-8") + validate_report_protocol(run_dir) + return { + "status": "section_written", + "mode": mode, + "section_id": section_id, + "component_name": component_name, + "path": str(section_path), + } + + +def validate_generated_report_section( + *, + run_dir: Path, + section_id: str, + component_name: str | None = None, +) -> dict[str, Any]: + section_path = _section_path(run_dir, section_id) + if not section_path.exists(): + raise ValueError(f"Section does not exist in scaffold: {section_path}") + code = section_path.read_text(encoding="utf-8") + component = component_name or _section_export(section_path) + _validate_section_code_safety(code, component_name=component, section_id=section_id) + validate_report_protocol(run_dir) + return { + "status": "section_valid", + "section_id": section_id, + "component_name": component, + "path": str(section_path), + } + + +def _report_section_imports(report_text: str) -> dict[str, str]: + imports: dict[str, str] = {} + pattern = re.compile( + r'import\s+\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\s+from\s+["\']\.\/sections\/([^"\']+)["\']' + ) + for component, section_stem in pattern.findall(report_text): + if component in imports: + raise ValueError(f"Report.tsx imports duplicate component identifier: {component}") + imports[component] = section_stem + return imports + + +def _rendered_components(report_text: str) -> list[str]: + names = re.findall(r"<([A-Z][A-Za-z0-9]*)\b", report_text) + return [name for name in names if name not in {"React"}] + + +def validate_report_protocol(run_dir: Path) -> None: + report_path = run_dir / "report" / "Report.tsx" + if not report_path.exists(): + raise ValueError(f"Report.tsx missing: {report_path}") + + report_text = report_path.read_text(encoding="utf-8") + section_files = sorted((run_dir / "report" / "sections").glob("*.tsx")) + if not section_files: + raise ValueError("Report scaffold must contain at least one generated section file") + + expected_by_stem: dict[str, str] = {} + for section_path in section_files: + expected_by_stem[section_path.stem] = _section_export(section_path) + + exported_components = list(expected_by_stem.values()) + if len(set(exported_components)) != len(exported_components): + raise ValueError("Section component identifiers must be unique") + + imports = _report_section_imports(report_text) + imported_by_stem = {section_stem: component for component, section_stem in imports.items()} + if set(imported_by_stem) != set(expected_by_stem): + raise ValueError("Report.tsx section imports do not exactly match generated section files") + for section_stem, exported_component in expected_by_stem.items(): + imported_component = imported_by_stem[section_stem] + if imported_component != exported_component: + raise ValueError( + f"Report.tsx imports {imported_component} but {section_stem} exports {exported_component}" + ) + + rendered = _rendered_components(report_text) + if sorted(rendered) != sorted(exported_components): + raise ValueError("Report.tsx render linkage does not exactly match generated sections") + + raw_blocks_dir = run_dir / "report" / "raw-blocks" + if not raw_blocks_dir.exists() or not raw_blocks_dir.is_dir(): + raise ValueError(f"raw-blocks directory missing: {raw_blocks_dir}") + + payload_path = run_dir / "report" / "payload" / "approved-query-result.json" + if not payload_path.exists(): + raise ValueError(f"approved query result payload missing: {payload_path}") + _read_json(payload_path) + + context_path = run_dir / "report" / "payload" / "report-context.json" + if not context_path.exists(): + raise ValueError(f"report context payload missing: {context_path}") + _read_json(context_path) + + +def scaffold_report_workspace( + run_dir: Path, + template_dir: Path, + sections: list[str], + payload: dict[str, Any], + *, + force: bool = False, +) -> dict[str, Any]: + if not sections: + raise ValueError("At least one report section is required") + + _ensure_can_write(run_dir, force) + _copy_template(template_dir, run_dir) + _write_payload(run_dir, payload) + generated_sections = _create_section_files(run_dir, sections) + _write_report_entry(run_dir, generated_sections) + validate_report_protocol(run_dir) + return {"section_count": len(sections), "run_dir": str(run_dir)} diff --git a/skill_scripts/report_sql_builder.py b/skill_scripts/report_sql_builder.py new file mode 100644 index 0000000..55bd3e7 --- /dev/null +++ b/skill_scripts/report_sql_builder.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any + +from skill_scripts.excel_intake import WorkbookRequirement +from skill_scripts.relationship_graph import infer_relationships +from skill_scripts.sql2000_guard import validate_sql + + +JsonDict = dict[str, Any] + + +@dataclass(frozen=True) +class ReportSqlFilter: + field_name: str + operator: str + value: str + + +@dataclass(frozen=True) +class ReportSqlSort: + output_name: str + direction: str = "DESC" + + +def _safe_identifier(name: str) -> str: + return f"[{str(name).strip()}]" + + +def _format_table_3part(db_prefix: str, table_id: str) -> str: + db = str(db_prefix or "").strip().rstrip(".") + parts = [part for part in db.split(".") if part] + if len(parts) >= 2: + db_name, schema = parts[0], parts[1] + elif len(parts) == 1: + db_name, schema = parts[0], "dbo" + else: + db_name, schema = "DSCSYS", "dbo" + return f"{_safe_identifier(db_name)}.{_safe_identifier(schema)}.{_safe_identifier(table_id)}" + + +def _table_rows(bundle: JsonDict) -> dict[str, JsonDict]: + return { + str(table.get("TableID", "")).strip().upper(): table + for table in bundle.get("tables", []) + if str(table.get("TableID", "")).strip() + } + + +def _field_exists(bundle: JsonDict, table_id: str, field_id: str) -> bool: + for field in bundle.get("fields", []): + current_table = str(field.get("TableID", "")).strip().upper() + current_id = str(field.get("ID", "") or field.get("FieldID", "")).strip().upper() + if current_table == table_id.upper() and current_id == field_id.upper(): + return True + return False + + +def _validate_required_fields(bundle: JsonDict, requirement: WorkbookRequirement) -> None: + tables = _table_rows(bundle) + for field in requirement.database_fields: + table_id = field.table.strip().upper() + field_id = field.field.strip().upper() + if table_id not in tables: + raise ValueError(f"SCHEMA_TABLE_NOT_FOUND:{field.table}") + if not _field_exists(bundle, table_id, field_id): + raise ValueError(f"SCHEMA_FIELD_NOT_FOUND:{field.table}.{field.field}") + + +def _required_tables(requirement: WorkbookRequirement) -> list[str]: + tables: list[str] = [] + for field in requirement.database_fields: + table_id = field.table.strip().upper() + if table_id and table_id not in tables: + tables.append(table_id) + return tables + + +def _relationship_for(bundle: JsonDict, tables: list[str]) -> JsonDict | None: + if len(tables) != 2: + return None + for edge in infer_relationships(bundle.get("fields", []), bundle.get("index_keys", [])): + from_table = str(edge.get("from_table", "")).strip().upper() + to_table = str(edge.get("to_table", "")).strip().upper() + if {from_table, to_table} == set(tables): + return edge + return None + + +def _aliases(bundle: JsonDict, tables: list[str]) -> tuple[dict[str, str], str, str, list[str]]: + table_rows = _table_rows(bundle) + if len(tables) == 1: + table = table_rows[tables[0]] + from_clause = f"FROM {_format_table_3part(str(table.get('DB', '')), tables[0])} header" + return {tables[0]: "header"}, from_clause, from_clause, [] + + edge = _relationship_for(bundle, tables) + if not edge: + raise ValueError(f"SCHEMA_RELATIONSHIP_NOT_FOUND:{','.join(tables)}") + + parent = str(edge["from_table"]).strip().upper() + child = str(edge["to_table"]).strip().upper() + parent_row = table_rows[parent] + child_row = table_rows[child] + join_conditions = [ + f"header.{_safe_identifier(left)} = detail.{_safe_identifier(right)}" + for left, right in zip(edge["from_columns"], edge["to_columns"], strict=False) + ] + join_sql = ( + f"FROM {_format_table_3part(str(parent_row.get('DB', '')), parent)} header\n" + f"JOIN {_format_table_3part(str(child_row.get('DB', '')), child)} detail\n" + f" ON {' AND '.join(join_conditions)}" + ) + return {parent: "header", child: "detail"}, join_sql, join_sql, join_conditions + + +def _display_map(requirement: WorkbookRequirement, aliases: dict[str, str]) -> dict[str, tuple[str, str]]: + mapping: dict[str, tuple[str, str]] = {} + for field in requirement.database_fields: + if not field.display_name or field.display_name in mapping: + continue + table_id = field.table.strip().upper() + mapping[field.display_name] = (aliases[table_id], field.field.strip().upper()) + return mapping + + +def _formula_map(requirement: WorkbookRequirement) -> dict[str, str]: + return {field.name: field.formula.lstrip("=").strip() for field in requirement.formula_fields} + + +def _replace_names(body: str, resolver) -> str: + names = sorted(resolver.names(), key=len, reverse=True) + rendered = body + for name in names: + if name not in rendered: + continue + rendered = rendered.replace(name, resolver.resolve(name)) + return rendered + + +class _ExpressionResolver: + def __init__( + self, + display_fields: dict[str, tuple[str, str]], + formulas: dict[str, str], + alias_overrides: dict[str, str] | None = None, + ): + self.display_fields = display_fields + self.formulas = formulas + self.alias_overrides = alias_overrides or {} + self._resolving: set[str] = set() + + def names(self) -> set[str]: + return set(self.display_fields) | set(self.formulas) + + def resolve(self, name: str) -> str: + if name in self.display_fields: + alias, field_id = self.display_fields[name] + alias = self.alias_overrides.get(alias, alias) + return f"{alias}.{_safe_identifier(field_id)}" + if name in self.formulas: + if name in self._resolving: + raise ValueError(f"FORMULA_CYCLE:{name}") + self._resolving.add(name) + try: + return _replace_names(self.formulas[name], self) + finally: + self._resolving.remove(name) + raise ValueError(f"FORMULA_REFERENCE_NOT_FOUND:{name}") + + +def _expand_expression(body: str, resolver: _ExpressionResolver) -> str: + expanded = _replace_names(body.lstrip("=").strip(), resolver) + return re.sub(r"\s*([+\-*/])\s*", r" \1 ", expanded).strip() + + +def _compile_filter(filter_item: ReportSqlFilter, resolver: _ExpressionResolver) -> str: + left = resolver.resolve(filter_item.field_name) + operator = filter_item.operator.strip().lower() + value = str(filter_item.value).replace("'", "''") + if operator in {"=", ">=", "<=", ">", "<"}: + return f"{left} {operator} '{value}'" + if operator == "not_equals_or_blank": + return f"ISNULL({left}, '') <> '{value}'" + raise ValueError(f"UNSUPPORTED_FILTER_OPERATOR:{filter_item.operator}") + + +def _compile_report_field( + output_name: str, + source_name: str, + formula: str, + resolver: _ExpressionResolver, + grand_resolver: _ExpressionResolver, +) -> tuple[str, str | None, str | None]: + if source_name: + expr = resolver.resolve(source_name) + return f"{expr} AS {output_name}", expr, None + + body = formula.lstrip("=").replace(" ", "") + sum_match = re.fullmatch(r"SUM\((.+)\)", body, flags=re.IGNORECASE) + if sum_match: + expr = _expand_expression(sum_match.group(1), resolver) + return f"SUM({expr}) AS {output_name}", None, None + + pct_match = re.fullmatch(r"(.+)/SUM\(\1\)\*100", body, flags=re.IGNORECASE) + if pct_match: + base_name = pct_match.group(1) + numerator = _expand_expression(base_name, resolver) + denominator = _expand_expression(base_name, grand_resolver) + return ( + "CAST(\n" + " CASE\n" + " WHEN grand.grand_total_amount = 0 THEN 0\n" + f" ELSE SUM({numerator}) * 100.0 / grand.grand_total_amount\n" + " END AS decimal(18, 4)\n" + f" ) AS {output_name}", + None, + denominator, + ) + + expr = _expand_expression(body, resolver) + return f"{expr} AS {output_name}", None, None + + +def build_report_select_sql( + prompt: str, + bundle: JsonDict, + requirement: WorkbookRequirement, + *, + filters: list[ReportSqlFilter] | None = None, + sorts: list[ReportSqlSort] | None = None, +) -> str: + del prompt + _validate_required_fields(bundle, requirement) + tables = _required_tables(requirement) + aliases, join_sql, _, _ = _aliases(bundle, tables) + display_fields = _display_map(requirement, aliases) + formulas = _formula_map(requirement) + resolver = _ExpressionResolver(display_fields, formulas) + grand_resolver = _ExpressionResolver( + display_fields, + formulas, + {"header": "grand_header", "detail": "grand_detail"}, + ) + + select_items = [] + group_by = [] + grand_total_expr = None + for report_field in requirement.report_fields: + select_sql, group_expr, denominator_expr = _compile_report_field( + report_field.output_name, + report_field.source_name, + report_field.formula, + resolver, + grand_resolver, + ) + select_items.append(select_sql) + if group_expr: + group_by.append(group_expr) + if denominator_expr: + grand_total_expr = denominator_expr + if "grand.grand_total_amount" not in group_by: + group_by.append("grand.grand_total_amount") + + where_items = [_compile_filter(filter_item, resolver) for filter_item in filters or []] + where_sql = f"\nWHERE {' AND '.join(where_items)}" if where_items else "" + group_sql = f"\nGROUP BY {', '.join(group_by)}" if group_by else "" + order_items = [] + for sort in sorts or []: + direction = sort.direction.strip().upper() + if direction not in {"ASC", "DESC"}: + raise ValueError(f"UNSUPPORTED_SORT_DIRECTION:{sort.direction}") + order_items.append(f"{sort.output_name} {direction}") + order_sql = f"\nORDER BY {', '.join(order_items)}" if order_items else "" + + if grand_total_expr: + grand_join_sql = join_sql.replace(" header", " grand_header").replace(" detail", " grand_detail") + grand_where_items = [ + _compile_filter( + filter_item, + _ExpressionResolver( + display_fields, + formulas, + {"header": "grand_header", "detail": "grand_detail"}, + ), + ) + for filter_item in filters or [] + ] + grand_where_sql = f"\n WHERE {' AND '.join(grand_where_items)}" if grand_where_items else "" + cross_join = ( + "\nCROSS JOIN (\n" + f" SELECT SUM({grand_total_expr}) AS grand_total_amount\n" + f" {grand_join_sql.replace(chr(10), chr(10) + ' ')}" + f"{grand_where_sql}\n" + ") grand" + ) + select_items = [ + item if "grand.grand_total_amount" not in item else item + for item in select_items + ] + else: + cross_join = "" + + sql = ( + "SELECT\n " + + ",\n ".join(select_items) + + "\n" + + join_sql + + cross_join + + where_sql + + group_sql + + order_sql + ) + ok, code = validate_sql(sql) + if not ok: + raise ValueError(code) + return sql diff --git a/skill_scripts/schema_context_builder.py b/skill_scripts/schema_context_builder.py new file mode 100644 index 0000000..3c94a0d --- /dev/null +++ b/skill_scripts/schema_context_builder.py @@ -0,0 +1,168 @@ +from collections import defaultdict +from typing import Any + +from skill_scripts.relationship_graph import infer_relationships + + +JsonDict = dict[str, Any] + + +def _normalize_text(value: str) -> str: + return str(value or "").strip().lower() + + +def _table_score(prompt: str, table_row: JsonDict, module_map: dict[str, JsonDict]) -> int: + p = _normalize_text(prompt) + score = 0 + + table_id = _normalize_text(table_row.get("TableID", "")) + if table_id and table_id in p: + score += 120 + + for key in ("TableName", "TableNameViet"): + name = _normalize_text(table_row.get(key, "")) + if name and name in p: + score += 90 + + module_id = _normalize_text(table_row.get("ModuleID", "")) + if module_id and module_id in p: + score += 30 + + module_row = module_map.get(module_id.upper(), {}) + for key in ("ModuleName", "ModuleNameViet"): + value = _normalize_text(module_row.get(key, "")) + if value and value in p: + score += 25 + + if "預算" in prompt and table_id in {"actmk", "actmj", "actmi"}: + score += 80 + if "明細" in prompt and table_id == "actmk": + score += 80 + + return score + + +def _find_requested_fields(prompt: str, fields: list[JsonDict], max_hits: int = 80) -> list[JsonDict]: + hits: list[JsonDict] = [] + seen: set[tuple[str, str]] = set() + + for row in fields: + field_id = str(row.get("ID", "")).strip() + field_name = str(row.get("FieldName", "")).strip() + field_viet = str(row.get("NameVietnam", "")).strip() + if not field_id: + continue + + matched = False + if field_id and field_id.lower() in prompt.lower(): + matched = True + elif field_name and field_name in prompt: + matched = True + elif field_viet and field_viet.lower() in prompt.lower(): + matched = True + + if matched: + key = (str(row.get("TableID", "")).strip(), field_id) + if key not in seen: + seen.add(key) + hits.append(row) + if len(hits) >= max_hits: + break + + return hits + + +def _expand_related_tables(base_table_ids: set[str], index_keys: list[JsonDict], fields: list[JsonDict]) -> set[str]: + if not base_table_ids: + return set() + + edges = infer_relationships(fields, index_keys) + related = set(base_table_ids) + for edge in edges: + confidence = str(edge.get("confidence", "")).strip().lower() + if confidence not in {"high", "medium"}: + continue + left = str(edge.get("from_table", "")).strip().upper() + right = str(edge.get("to_table", "")).strip().upper() + if left in base_table_ids or right in base_table_ids: + related.add(left) + related.add(right) + + return related + + +def build_context_slice(prompt: str, bundle: JsonDict, top_k: int = 8, max_columns_per_table: int = 30) -> JsonDict: + tables = bundle.get("tables", []) + fields = bundle.get("fields", []) + index_keys = bundle.get("index_keys", []) + modules = {str(m.get("ModuleID", "")).strip().upper(): m for m in bundle.get("modules", [])} + + ranked_tables = sorted( + tables, + key=lambda table: ( + -_table_score(prompt, table, modules), + str(table.get("TableID", "")).strip(), + ), + ) + selected_tables = ranked_tables[: max(1, top_k)] + selected_ids = {str(t.get("TableID", "")).strip().upper() for t in selected_tables} + + field_hits = _find_requested_fields(prompt, fields) + for hit in field_hits: + tid = str(hit.get("TableID", "")).strip().upper() + if tid: + selected_ids.add(tid) + + selected_ids = _expand_related_tables(selected_ids, index_keys, fields) + + selected_tables = [t for t in ranked_tables if str(t.get("TableID", "")).strip().upper() in selected_ids] + if len(selected_tables) > top_k: + selected_tables = selected_tables[:top_k] + selected_ids = {str(t.get("TableID", "")).strip().upper() for t in selected_tables} + + columns_by_table: dict[str, list[JsonDict]] = defaultdict(list) + for row in fields: + tid = str(row.get("TableID", "")).strip().upper() + if tid in selected_ids: + columns_by_table[tid].append( + { + "ID": str(row.get("ID", "")).strip(), + "FieldName": str(row.get("FieldName", "")).strip(), + "NameVietnam": str(row.get("NameVietnam", "")).strip(), + "Type": row.get("Type", ""), + "Length": row.get("Length", ""), + } + ) + + compact_columns = { + table_id: cols[:max_columns_per_table] + for table_id, cols in columns_by_table.items() + } + + relationships = [ + edge + for edge in infer_relationships(fields, index_keys) + if str(edge.get("from_table", "")).strip().upper() in selected_ids + and str(edge.get("to_table", "")).strip().upper() in selected_ids + and str(edge.get("confidence", "")).strip().lower() in {"high", "medium"} + ] + + requested_columns = [ + f"{str(r.get('TableID', '')).strip()}.{str(r.get('ID', '')).strip()}" + for r in field_hits + ] + + return { + "tables": [ + { + "TableID": str(t.get("TableID", "")).strip(), + "TableName": str(t.get("TableName", "")).strip(), + "ModuleID": str(t.get("ModuleID", "")).strip(), + "DB": str(t.get("DB", "")).strip(), + } + for t in selected_tables + ], + "columns": compact_columns, + "relationships": relationships, + "requested_columns": requested_columns, + } diff --git a/skill_scripts/schema_loader.py b/skill_scripts/schema_loader.py new file mode 100644 index 0000000..d2fe8bd --- /dev/null +++ b/skill_scripts/schema_loader.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path +from typing import Any + + +JsonDict = dict[str, Any] + + +def _read_json(path: Path): + with path.open("r", encoding="utf-8") as fp: + return json.load(fp) + + +def _normalize_table_row(row: JsonDict) -> JsonDict: + out = dict(row) + out["TableID"] = str(out.get("TableID", "")).strip() + out["ModuleID"] = str(out.get("ModuleID", "")).strip() + out["DB"] = str(out.get("DB", "")).strip() + return out + + +def _normalize_field_row(row: JsonDict) -> JsonDict: + out = dict(row) + out["TableID"] = str(out.get("TableID", "")).strip() + out["ID"] = str(out.get("ID", "")).strip() + out["ModuleID"] = str(out.get("ModuleID", "")).strip() + out["DB"] = str(out.get("DB", "")).strip() + return out + + +def _normalize_index_row(row: JsonDict) -> JsonDict: + out = dict(row) + out["TableName"] = str(out.get("TableName", "")).strip() + out["IndexColumnName"] = str(out.get("IndexColumnName", "")).strip() + out["isPrimaryKey"] = str(out.get("isPrimaryKey", "")).strip() + return out + + +def _normalize_module_row(row: JsonDict) -> JsonDict: + out = dict(row) + out["ModuleID"] = str(out.get("ModuleID", "")).strip() + return out + + +def load_schema_bundle(source_dir: str = "_Source"): + base = Path(source_dir) + bundle = { + "modules": [_normalize_module_row(r) for r in _read_json(base / "MoudleName.json")], + "tables": [_normalize_table_row(r) for r in _read_json(base / "TableName.json")], + "fields": [_normalize_field_row(r) for r in _read_json(base / "TableStructure.json")], + "index_keys": [_normalize_index_row(r) for r in _read_json(base / "TableIndexKey.json")], + } + return bundle diff --git a/skill_scripts/schema_metadata.py b/skill_scripts/schema_metadata.py new file mode 100644 index 0000000..2b4ca07 --- /dev/null +++ b/skill_scripts/schema_metadata.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +MISSING_DESCRIPTION = "schema description missing" + + +class SchemaMetadata: + def __init__(self, table_names: dict[str, str], field_names: dict[tuple[str, str], str]): + self._table_names = table_names + self._field_names = field_names + + @classmethod + def from_source_dir(cls, source_dir: str | Path) -> "SchemaMetadata": + source = Path(source_dir) + table_rows = json.loads((source / "TableName.json").read_text(encoding="utf-8")) + field_rows = json.loads((source / "TableStructure.json").read_text(encoding="utf-8")) + + table_names: dict[str, str] = {} + for row in table_rows: + table_id = str(row.get("TableID", "")).strip().upper() + table_name = str(row.get("TableName", "") or row.get("TableNameCHT", "") or "").strip() + if table_id: + table_names[table_id] = table_name or MISSING_DESCRIPTION + + field_names: dict[tuple[str, str], str] = {} + for row in field_rows: + table_id = str(row.get("TableID", "")).strip().upper() + column_id = str(row.get("ID", "")).strip().upper() + column_name = str(row.get("FieldName", "") or row.get("Name", "") or "").strip() + if table_id and column_id: + field_names[(table_id, column_id)] = column_name or MISSING_DESCRIPTION + + return cls(table_names, field_names) + + def describe_field( + self, + table_id: str, + column_id: str, + join_reason: str = "", + business_meaning: str = "", + ) -> dict[str, Any]: + table_key = table_id.strip().upper() + column_key = column_id.strip().upper() + table_name = self._table_names.get(table_key, MISSING_DESCRIPTION) + column_name = self._field_names.get((table_key, column_key), MISSING_DESCRIPTION) + metadata_status = ( + "ok" if table_name != MISSING_DESCRIPTION and column_name != MISSING_DESCRIPTION else "warning" + ) + meaning = business_meaning.strip() if business_meaning else f"{table_key}.{column_key} from WFERP schema" + + return { + "table_id": table_key, + "table_name": table_name, + "column_id": column_key, + "column_name": column_name, + "join_reason": join_reason, + "business_meaning": meaning, + "metadata_status": metadata_status, + } + + def describe_expression(self, inputs: list[dict[str, Any]]) -> dict[str, Any]: + described_inputs = [ + self.describe_field( + str(item.get("table_id", "")), + str(item.get("column_id", "")), + business_meaning=str(item.get("reason", "")), + ) + for item in inputs + ] + metadata_status = ( + "warning" + if any(item["metadata_status"] == "warning" for item in described_inputs) + else "ok" + ) + return {"inputs": described_inputs, "metadata_status": metadata_status} diff --git a/skill_scripts/single_html_exporter.py b/skill_scripts/single_html_exporter.py new file mode 100644 index 0000000..e82d73f --- /dev/null +++ b/skill_scripts/single_html_exporter.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import base64 +import gzip +import hashlib +from html import escape +import json +from pathlib import Path +from typing import Any, Mapping + +from skill_scripts.report_package import validate_report_package +from skill_scripts.style_replay import build_style_capsule + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _pretty_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _package_hash(package: Mapping[str, Any]) -> str: + hashes = package.get("hashes") + package_sha = hashes.get("package_sha256") if isinstance(hashes, Mapping) else None + if isinstance(package_sha, str) and package_sha: + return package_sha + return hashlib.sha256(_canonical_json(package).encode("utf-8")).hexdigest() + + +def _sql_text(package: Mapping[str, Any]) -> str: + sql = package.get("sql") + if isinstance(sql, Mapping): + return str(sql.get("text") or "") + return "" + + +def _report_title(package: Mapping[str, Any], brief: Mapping[str, Any]) -> str: + for source in (brief, package): + for key in ("title", "report_title"): + value = source.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + prompt = str(package.get("prompt") or "") + if "費用" in prompt: + return "費用分析管理報表" + report_type = package.get("report_type") or package.get("catalog_guardrail") + if isinstance(report_type, str) and report_type.strip(): + return f"{report_type.strip()} 管理報表" + return "WFERP 管理報表" + + +def _row_count(package: Mapping[str, Any]) -> int: + data_profile = package.get("data_profile") + if isinstance(data_profile, Mapping): + value = data_profile.get("row_count") + if isinstance(value, int) and value >= 0: + return value + datasets = package.get("datasets") + rows = datasets.get("embedded_rows") if isinstance(datasets, Mapping) else None + return len(rows) if isinstance(rows, list) else 0 + + +def _embedded_rows(package: Mapping[str, Any]) -> list[Mapping[str, Any]]: + datasets = package.get("datasets") + rows = datasets.get("embedded_rows") if isinstance(datasets, Mapping) else None + if not isinstance(rows, list): + return [] + return [row for row in rows if isinstance(row, Mapping)] + + +def _columns(package: Mapping[str, Any]) -> list[str]: + data_profile = package.get("data_profile") + if isinstance(data_profile, Mapping) and isinstance(data_profile.get("columns"), list): + return [str(column) for column in data_profile["columns"]] + datasets = package.get("datasets") + dataset_columns = datasets.get("columns") if isinstance(datasets, Mapping) else None + if isinstance(dataset_columns, list): + return [str(column) for column in dataset_columns] + columns: list[str] = [] + for row in _embedded_rows(package): + for key in row: + key_text = str(key) + if key_text not in columns: + columns.append(key_text) + return columns + + +def _aggregates(package: Mapping[str, Any]) -> Mapping[str, Any]: + aggregates = package.get("aggregates") + return aggregates if isinstance(aggregates, Mapping) else {} + + +def _format_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "是" if value else "否" + if isinstance(value, (int, float)): + if isinstance(value, float) and abs(value) <= 1: + return f"{value:.2f}" + return f"{value:,.0f}" if float(value).is_integer() else f"{value:,.2f}" + return str(value) + + +def _first_text_column(columns: list[str], rows: list[Mapping[str, Any]]) -> str | None: + for column in columns: + if any(isinstance(row.get(column), str) for row in rows): + return column + return columns[0] if columns else None + + +def _first_numeric_column(columns: list[str], rows: list[Mapping[str, Any]]) -> str | None: + preferred = ["amount", "variance_amount", "budget_amount"] + for column in preferred: + if column in columns and any(isinstance(row.get(column), (int, float)) for row in rows): + return column + for column in columns: + if any(isinstance(row.get(column), (int, float)) for row in rows): + return column + return None + + +def _chart_data(columns: list[str], rows: list[Mapping[str, Any]]) -> list[dict[str, Any]]: + label_column = _first_text_column(columns, rows) + value_column = _first_numeric_column(columns, rows) + if not label_column or not value_column: + return [] + totals: dict[str, float] = {} + for row in rows: + label = str(row.get(label_column) or "未分類") + raw_value = row.get(value_column) + value = float(raw_value) if isinstance(raw_value, (int, float)) else 0 + totals[label] = totals.get(label, 0) + value + return [{"label": label, "value": value} for label, value in totals.items()] + + +def _render_kpi_cards(package: Mapping[str, Any]) -> str: + aggregates = _aggregates(package) + kpis = [ + ("資料筆數", _row_count(package)), + ("總費用", aggregates.get("total_amount")), + ("總預算", aggregates.get("total_budget")), + ("預算差異", aggregates.get("variance_amount")), + ("最高費用占比", aggregates.get("max_expense_ratio")), + ] + cards = [] + for label, value in kpis: + if value is None: + continue + cards.append( + f'
    {escape(label)}{escape(_format_value(value))}
    ' + ) + return "\n".join(cards) + + +def _render_chart(chart_data: list[dict[str, Any]]) -> str: + if not chart_data: + return '

    目前資料不足以產生圖表。

    ' + width = 760 + height = 300 + plot_bottom = 238 + plot_top = 34 + plot_height = plot_bottom - plot_top + max_value = max((datum["value"] for datum in chart_data), default=1) or 1 + step = width / len(chart_data) + bars = [] + labels = [] + for index, datum in enumerate(chart_data): + value = datum["value"] + bar_height = max(2, (value / max_value) * plot_height) + x = step * index + step * 0.22 + y = plot_bottom - bar_height + bar_width = max(step * 0.56, 18) + label_x = step * index + step / 2 + bars.append( + f'{escape(str(datum["label"]))}: {escape(_format_value(value))}' + ) + labels.append( + f'{escape(str(datum["label"]))}' + ) + return ( + f'' + f'' + + "".join(bars) + + "".join(labels) + + "" + ) + + +def _render_table(columns: list[str], rows: list[Mapping[str, Any]]) -> str: + if not columns or not rows: + return '

    沒有可呈現的明細資料。

    ' + header = "".join(f"{escape(column)}" for column in columns) + body_rows = [] + for row in rows: + cells = "".join(f"{escape(_format_value(row.get(column)))}" for column in columns) + body_rows.append(f"{cells}") + return f'
    {header}{"".join(body_rows)}
    ' + + +def _render_analysis(package: Mapping[str, Any]) -> str: + aggregates = _aggregates(package) + total_amount = aggregates.get("total_amount") + total_budget = aggregates.get("total_budget") + variance = aggregates.get("variance_amount") + max_ratio = aggregates.get("max_expense_ratio") + rows = _embedded_rows(package) + highest = max(rows, key=lambda row: float(row.get("amount", 0) or 0), default={}) + highest_text = "" + if highest: + subject = highest.get("expense_subject") or highest.get("department_name") or highest.get("department") or "最高項目" + highest_text = f"最高費用項目為 {escape(str(subject))},金額 {escape(_format_value(highest.get('amount')))}。" + parts = [ + f"本次查詢回傳 {_row_count(package)} 筆資料。", + f"總費用 {escape(_format_value(total_amount))},總預算 {escape(_format_value(total_budget))},差異 {escape(_format_value(variance))}。", + ] + if max_ratio is not None: + parts.append(f"最高費用占比為 {escape(_format_value(max_ratio))},可作為優先追蹤依據。") + if highest_text: + parts.append(highest_text) + return " ".join(parts) + + +def _render_recommendations(package: Mapping[str, Any]) -> str: + aggregates = _aggregates(package) + variance = aggregates.get("variance_amount") + recommendations = [ + "針對高費用與高差異項目建立月度追蹤清單。", + "保留 SQL、資料預覽與 validator evidence,讓管理數字可追溯。", + ] + if isinstance(variance, (int, float)) and variance > 0: + recommendations.insert(0, "本期費用高於預算,建議優先檢視超支部門與科目。") + return "".join(f"
  • {escape(item)}
  • " for item in recommendations) + + +def _validator_status(package: Mapping[str, Any]) -> str: + summary = package.get("validator_summary") + if not isinstance(summary, list) or not summary: + return "unknown" + statuses = [item.get("status") for item in summary if isinstance(item, Mapping)] + if statuses and all(status == "pass" for status in statuses): + return "pass" + if any(status in {"fail", "error"} for status in statuses): + return "fail" + return str(statuses[0]) if statuses else "unknown" + + +def _encoded_payload(package: Mapping[str, Any], brief: Mapping[str, Any]) -> str: + payload = {"package": package, "brief": brief} + compressed = gzip.compress(_canonical_json(payload).encode("utf-8")) + return base64.b64encode(compressed).decode("ascii") + + +def _render_html(package: Mapping[str, Any], brief: Mapping[str, Any]) -> str: + encoded = _encoded_payload(package, brief) + title = _report_title(package, brief) + prompt = str(package.get("prompt") or "") + sql_text = _sql_text(package) + rows = _embedded_rows(package) + columns = _columns(package) + chart_data = _chart_data(columns, rows) + column_text = ", ".join(columns) if columns else "No columns provided" + escaped_title = escape(title) + escaped_prompt = escape(prompt) + escaped_sql = escape(sql_text) + escaped_column_text = escape(column_text) + kpi_cards = _render_kpi_cards(package) + chart = _render_chart(chart_data) + table = _render_table(columns, rows) + analysis = _render_analysis(package) + recommendations = _render_recommendations(package) + return f""" + + + + + {escaped_title} + + + +
    +

    {escaped_title}

    +

    {escaped_prompt}

    +
    +

    管理摘要

    +
    {kpi_cards}
    +

    資料欄位:{escaped_column_text}

    +
    +
    +

    圖表證明

    + {chart} +
    +
    +

    管理分析

    +

    {analysis}

    +
    +
    +

    建議事項

    +
      {recommendations}
    +
    +
    +

    資料明細

    + {table} +
    +
    +

    Query

    +
    {escaped_sql}
    +
    +
    + + + +""" + + +def export_single_html_report( + output_root: str | Path, + package: dict[str, Any], + brief: dict[str, Any], +) -> dict[str, Any]: + package_result = validate_report_package(package) + if not package_result["valid"]: + return {"status": "error", "errors": package_result["errors"]} + + output_path = Path(output_root) + delivery_dir = output_path / "delivery" + evidence_dir = delivery_dir / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + + package_text = _pretty_json(package) + brief_text = _pretty_json(brief) + style_capsule = build_style_capsule(brief) + sql_text = _sql_text(package) + (evidence_dir / "report-package.json").write_text(package_text, encoding="utf-8") + (evidence_dir / "report-design-brief.json").write_text(brief_text, encoding="utf-8") + (evidence_dir / "report-style-capsule.json").write_text(_pretty_json(style_capsule), encoding="utf-8") + (evidence_dir / "query.sql").write_text(sql_text + ("\n" if sql_text else ""), encoding="utf-8") + + html_path = delivery_dir / "report.html" + html_text = _render_html(package, brief) + html_path.write_text(html_text, encoding="utf-8") + html_sha256 = _sha256_text(html_text) + + manifest = { + "html_path": str(html_path), + "html_sha256": html_sha256, + "package_sha256": _package_hash(package), + "catalog_guardrail": package.get("catalog_guardrail"), + "row_count": _row_count(package), + "validator_status": _validator_status(package), + "style_fingerprint": style_capsule["style_fingerprint"], + } + (delivery_dir / "delivery-manifest.json").write_text(_pretty_json(manifest), encoding="utf-8") + + return {"status": "exported", "html_path": str(html_path), "html_sha256": html_sha256} diff --git a/skill_scripts/sql2000_guard.py b/skill_scripts/sql2000_guard.py new file mode 100644 index 0000000..3d4e8ed --- /dev/null +++ b/skill_scripts/sql2000_guard.py @@ -0,0 +1,53 @@ +import re + + +FORBIDDEN_NON_SELECT = ( + "insert", + "update", + "delete", + "create", + "alter", + "drop", + "merge", + "truncate", + "exec", + "execute", +) + +FORBIDDEN_SQL2000_REGEX = ( + r"\bwith\b\s+[\[\(a-zA-Z_][\w\]\)]*\s+as\s*\(", + r"\bover\b", + r"\bpartition\s+by\b", + r"\brow_number\b", + r"\brank\b", + r"\bdense_rank\b", + r"\boffset\b", + r"\bfetch\b", + r"\bexcept\b", + r"\bintersect\b", + r"\bconcat\s*\(", +) + + +def _statement_count(sql: str) -> int: + parts = [p.strip() for p in sql.split(";") if p.strip()] + return len(parts) + + +def validate_sql(sql: str) -> tuple[bool, str]: + s = str(sql or "").strip() + lowered = s.lower() + + if any(re.search(rf"\b{tok}\b", lowered) for tok in FORBIDDEN_NON_SELECT): + return False, "NON_SELECT_INTENT" + + if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in FORBIDDEN_SQL2000_REGEX): + return False, "UNSUPPORTED_SQL2000_FEATURE" + + if _statement_count(s) > 1: + return False, "MULTI_STATEMENT_NOT_ALLOWED" + + if not lowered.startswith("select"): + return False, "NON_SELECT_INTENT" + + return True, "OK" diff --git a/skill_scripts/sql_generator.py b/skill_scripts/sql_generator.py new file mode 100644 index 0000000..f776652 --- /dev/null +++ b/skill_scripts/sql_generator.py @@ -0,0 +1,121 @@ +import os + +from skill_scripts.intent_parser import parse_intent +from skill_scripts.sql2000_guard import validate_sql +from typing import Any + + +JsonDict = dict[str, Any] + + +def _safe_identifier(name: str) -> str: + return f"[{str(name).strip()}]" + + +def _format_table_3part(db_prefix: str, table_id: str) -> str: + db = str(db_prefix or "").strip().rstrip(".") + parts = [p for p in db.split(".") if p] + if len(parts) >= 2: + db_name, schema = parts[0], parts[1] + elif len(parts) == 1: + db_name, schema = parts[0], "dbo" + else: + db_name, schema = "DSCSYS", "dbo" + return f"{_safe_identifier(db_name)}.{_safe_identifier(schema)}.{_safe_identifier(table_id)}" + + +def _default_db_override() -> str: + raw = str(os.getenv("WFERP_DEFAULT_DB", "")).strip() + if not raw: + return "" + + value = raw.rstrip(".") + if "." not in value: + return f"{value}.dbo." + return f"{value}." + + +def _table_match_score(prompt: str, table_row: JsonDict, module_map: dict[str, JsonDict]) -> int: + p = prompt.lower() + score = 0 + table_id = str(table_row.get("TableID", "")).strip() + if table_id and table_id.lower() in p: + score += 100 + + for key in ("TableName", "TableNameViet"): + value = str(table_row.get(key, "")).strip().lower() + if value and value in p: + score += 80 + + module_id = str(table_row.get("ModuleID", "")).strip() + if module_id and module_id.lower() in p: + score += 30 + + module_row = module_map.get(module_id, {}) + for key in ("ModuleName", "ModuleNameViet"): + value = str(module_row.get(key, "")).strip().lower() + if value and value in p: + score += 30 + + if "採購" in prompt and table_id.startswith("PUR"): + score += 50 + + # domain keyword boost for budget queries + if "預算" in prompt and table_id in {"ACTMJ", "ACTMK", "ACTMI"}: + score += 70 + if "明細" in prompt and table_id == "ACTMK": + score += 90 + if "工程" in prompt and table_id == "ACTMK": + score += 30 + + return score + + +def _choose_table(prompt: str, bundle: JsonDict) -> JsonDict: + tables = bundle.get("tables", []) + modules = {str(m.get("ModuleID", "")).strip(): m for m in bundle.get("modules", [])} + + if not tables: + return {"DB": "DSCSYS.dbo.", "TableID": "ADMMC"} + + scored = sorted( + ((table, _table_match_score(prompt, table, modules)) for table in tables), + key=lambda item: (-item[1], str(item[0].get("TableID", "")).strip()), + ) + + best_table, best_score = scored[0] + if best_score <= 0: + for row in tables: + if str(row.get("TableID", "")).strip() == "ADMMC": + return row + return {"DB": "DSCSYS.dbo.", "TableID": "ADMMC"} + return best_table + + +def _year_filter_clause(table_id: str, year: str | None) -> str: + if not year: + return "" + if table_id == "ACTMJ": + return f" WHERE {_safe_identifier('MJ002')} = '{year}'" + if table_id == "ACTMK": + return f" WHERE {_safe_identifier('MK002')} = '{year}'" + return "" + + +def generate_select_sql(prompt: str, bundle: JsonDict) -> str: + intent = parse_intent(prompt) + if intent["non_select_intent"]: + raise ValueError("NON_SELECT_INTENT") + + table = _choose_table(intent["raw"], bundle) + table_id = str(table.get("TableID", "")).strip() or "ADMMC" + db = _default_db_override() or str(table.get("DB", "")).strip() or "DSCSYS.dbo." + top = intent["top"] + year = intent.get("year") + + select_prefix = f"SELECT TOP {top}" if top is not None else "SELECT" + sql = f"{select_prefix} * FROM {_format_table_3part(db, table_id)}{_year_filter_clause(table_id, year)}" + ok, code = validate_sql(sql) + if not ok: + raise ValueError(code) + return sql diff --git a/skill_scripts/sql_router.py b/skill_scripts/sql_router.py new file mode 100644 index 0000000..3678f72 --- /dev/null +++ b/skill_scripts/sql_router.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass +from typing import Any + +from skill_scripts.execution_validator import ExecutionExpectation, execute_and_validate +from skill_scripts.intent_parser import parse_intent +from skill_scripts.llm_sql_generator import build_llm_prompt, call_llm, parse_llm_response +from skill_scripts.metadata_validator import validate_metadata_references +from skill_scripts.prompt_sql_consistency import validate_prompt_sql_consistency +from skill_scripts.schema_context_builder import build_context_slice +from skill_scripts.sql2000_guard import validate_sql +from skill_scripts.sql_generator import generate_select_sql + + +JsonDict = dict[str, Any] + + +@dataclass(frozen=True) +class RoutingOptions: + mode: str = "llm-first" + llm_provider: str = "codex" + llm_model: str = "none" + llm_timeout_sec: float = 30.0 + min_confidence: float = 0.6 + llm_repair_attempts: int = 2 + validate_execution: bool = False + execution_expectation: ExecutionExpectation | None = None + allow_non_test_execution: bool = False + + +def _fallback(prompt: str, bundle: JsonDict, reason: str, route: str = "fallback_rule", candidate_sql: str = "") -> tuple[str, JsonDict]: + sql = generate_select_sql(prompt, bundle) + meta: JsonDict = {"route": route, "reason": reason} + if candidate_sql: + meta["candidate_sql"] = candidate_sql + return sql, meta + + +def _validate_llm_candidate( + prompt: str, + sql: str, + bundle: JsonDict, + db_client, + options: RoutingOptions, +) -> tuple[bool, str]: + ok_policy, code_policy = validate_sql(sql) + if not ok_policy: + return False, code_policy + + ok_meta, code_meta = validate_metadata_references(sql, bundle) + if not ok_meta: + return False, code_meta + + ok_consistency, code_consistency = validate_prompt_sql_consistency(prompt, sql) + if not ok_consistency: + return False, code_consistency + + if options.validate_execution and db_client is not None: + expectation = options.execution_expectation or ExecutionExpectation() + try: + ok_exec, code_exec, _ = execute_and_validate(sql, db_client, expectation) + except Exception: + return False, "EXECUTION_VALIDATION_ERROR" + if not ok_exec: + return False, code_exec + + if options.validate_execution and db_client is None: + return False, "DB_CLIENT_REQUIRED" + + return True, "OK" + + +def _build_repair_prompt(user_prompt: str, context: JsonDict, failed_sql: str, failed_reason: str) -> str: + base = build_llm_prompt(user_prompt, context) + if not failed_sql: + return base + return ( + f"{base}\n" + "Previous SQL candidate failed validation/execution. " + f"Failure code: {failed_reason}.\n" + f"Previous SQL: {failed_sql}\n" + "Rewrite SQL to fix the failure and return JSON only with keys: sql, used_tables, assumptions, confidence." + ) + + +def route_generate_sql( + prompt: str, + bundle: JsonDict, + options: RoutingOptions, + db_client=None, +) -> tuple[str, JsonDict]: + intent = parse_intent(prompt) + if intent.get("non_select_intent"): + raise ValueError("NON_SELECT_INTENT") + + mode = str(options.mode or "rule").strip().lower() + if mode == "rule": + return generate_select_sql(prompt, bundle), {"route": "rule", "reason": "RULE_MODE"} + + if options.validate_execution and db_client is not None and not options.allow_non_test_execution: + config = getattr(db_client, "config", None) + env = str(getattr(config, "env", "")).strip().lower() if config is not None else "" + if env != "test": + if mode == "shadow": + return _fallback(prompt, bundle, reason="DB_ENV_NOT_TEST", route="shadow_rule", candidate_sql="") + raise RuntimeError("DB_ENV_NOT_TEST") + + if options.validate_execution and db_client is not None and hasattr(db_client, "health_check"): + ok_health, code_health = db_client.health_check() + if not ok_health: + if mode == "shadow": + return _fallback(prompt, bundle, reason=code_health, route="shadow_rule", candidate_sql="") + raise RuntimeError(str(code_health)) + + context = build_context_slice(prompt, bundle) + failed_reason = "" + failed_sql = "" + attempts = max(1, int(options.llm_repair_attempts) + 1) + + for attempt in range(attempts): + llm_prompt = _build_repair_prompt(prompt, context, failed_sql=failed_sql, failed_reason=failed_reason) + + try: + raw_response = call_llm( + provider=options.llm_provider, + model=options.llm_model, + prompt_text=llm_prompt, + timeout_sec=options.llm_timeout_sec, + ) + llm_out = parse_llm_response(raw_response) + except RuntimeError as exc: + reason = str(exc) + if mode == "shadow": + return _fallback(prompt, bundle, reason=reason, route="shadow_rule", candidate_sql="") + if attempt >= attempts - 1: + raise + failed_reason = reason + failed_sql = "" + continue + + candidate_sql = str(llm_out.get("sql", "")).strip() + confidence = float(llm_out.get("confidence", 0.0)) + + ok = False + reason = "LOW_CONFIDENCE" + if confidence >= options.min_confidence: + ok, reason = _validate_llm_candidate( + prompt=prompt, + sql=candidate_sql, + bundle=bundle, + db_client=db_client, + options=options, + ) + + if mode == "shadow": + return _fallback( + prompt, + bundle, + reason="SHADOW_COMPARE" if ok else reason, + route="shadow_rule", + candidate_sql=candidate_sql, + ) + + if ok: + return candidate_sql, {"route": "llm", "reason": "OK"} + + failed_reason = reason + failed_sql = candidate_sql + + raise RuntimeError(f"LLM_REPAIR_FAILED:{failed_reason or 'UNKNOWN_ERROR'}") diff --git a/skill_scripts/sqlite_enrichment.py b/skill_scripts/sqlite_enrichment.py new file mode 100644 index 0000000..5d3c6fe --- /dev/null +++ b/skill_scripts/sqlite_enrichment.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import re +import sqlite3 +from typing import Any + +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace + + +def _quote_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _validate_lookup_table(workspace: SQLiteRunWorkspace, table_name: str) -> None: + if table_name not in workspace.manifest().get("lookup_tables", []): + raise ValueError(f"Lookup table is not registered in workspace manifest: {table_name}") + + +_IDENTIFIER_PATTERN = r'(?:raw\.)?"(?:[^"]|"")+"' +_NUMBER_PATTERN = r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)' +_VALUE_PATTERN = rf'(?:{_IDENTIFIER_PATTERN}|{_NUMBER_PATTERN})' +_BINARY_EXPR_RE = re.compile(rf'\A\(?\s*{_VALUE_PATTERN}\s*[-*]\s*{_VALUE_PATTERN}\s*\)?\Z') +_SCALED_BINARY_EXPR_RE = re.compile( + rf'\A{_NUMBER_PATTERN}\s*\*\s*\(\s*{_IDENTIFIER_PATTERN}\s*[-*]\s*{_IDENTIFIER_PATTERN}\s*\)\Z' +) +_SIMPLE_EXPR_RE = re.compile(rf'\A(?:{_VALUE_PATTERN})\Z') +_SUBSTR_EXPR_RE = re.compile(rf'\ASUBSTR\(\s*{_IDENTIFIER_PATTERN}\s*,\s*\d+\s*,\s*\d+\s*\)\Z', re.IGNORECASE) +_CASE_LEFT_EXPR_RE = re.compile( + rf"\ACASE WHEN SUBSTR\(\s*{_IDENTIFIER_PATTERN}\s*,\s*1\s*,\s*\d+\s*\)\s*=\s*'[^']*'\s+" + rf"THEN\s+'[^']*'\s+ELSE SUBSTR\(\s*{_IDENTIFIER_PATTERN}\s*,\s*1\s*,\s*\d+\s*\)\s+END\Z", + re.IGNORECASE, +) + + +def _validate_computed_expression(expression: str) -> None: + text = expression.strip() + if not text: + raise ValueError("Unsafe computed expression: expression cannot be empty") + if ( + _SIMPLE_EXPR_RE.fullmatch(text) + or _BINARY_EXPR_RE.fullmatch(text) + or _SCALED_BINARY_EXPR_RE.fullmatch(text) + or _SUBSTR_EXPR_RE.fullmatch(text) + or _CASE_LEFT_EXPR_RE.fullmatch(text) + ): + return + raise ValueError(f"Unsafe computed expression: {expression}") + + +def run_enrichment( + workspace: SQLiteRunWorkspace, + *, + computed_columns: list[dict[str, str]], + lookup_columns: list[dict[str, str]], +) -> dict[str, Any]: + select_parts = ["raw.*"] + joins: list[str] = [] + + for index, lookup in enumerate(lookup_columns): + lookup_table = lookup["lookup_table"] + _validate_lookup_table(workspace, lookup_table) + alias = f"lk{index}" + joins.append( + f"LEFT JOIN {_quote_identifier(lookup_table)} {alias} " + f"ON {alias}.{_quote_identifier(lookup['lookup_key'])} = " + f"raw.{_quote_identifier(lookup['raw_key'])}" + ) + select_parts.append( + f"{alias}.{_quote_identifier(lookup['lookup_value'])} AS {_quote_identifier(lookup['name'])}" + ) + + for column in computed_columns: + expression = column["expression"] + _validate_computed_expression(expression) + select_parts.append(f"{expression} AS {_quote_identifier(column['name'])}") + + enrichment_sql = ( + f"CREATE TABLE {_quote_identifier(workspace.enriched_table)} AS " + f"SELECT {', '.join(select_parts)} " + f"FROM {_quote_identifier(workspace.raw_table)} raw" + ) + if joins: + enrichment_sql += " " + " ".join(joins) + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + conn.execute(f"DROP TABLE IF EXISTS {_quote_identifier(workspace.enriched_table)}") + conn.execute(enrichment_sql) + row_count = conn.execute( + f"SELECT COUNT(*) FROM {_quote_identifier(workspace.enriched_table)}" + ).fetchone()[0] + + manifest = workspace.manifest() + manifest["enriched_row_count"] = row_count + manifest["enrichment_sql"] = enrichment_sql + workspace._write_manifest(manifest) + + return { + "status": "enriched", + "enriched_table": workspace.enriched_table, + "enriched_row_count": row_count, + "sql": enrichment_sql, + } diff --git a/skill_scripts/sqlite_readonly_adapter.py b/skill_scripts/sqlite_readonly_adapter.py new file mode 100644 index 0000000..c4ab80d --- /dev/null +++ b/skill_scripts/sqlite_readonly_adapter.py @@ -0,0 +1,34 @@ +import re + +from skill_scripts.postgres_readonly_adapter import ( + _quote_wferp_columns, + _reject_unsafe_sql, + _translate_top, +) + + +def _translate_sqlserver_tables(sql: str) -> str: + translated = re.sub( + r"\[[A-Za-z_][A-Za-z0-9_]*\]\.\[dbo\]\.\[([A-Za-z_][A-Za-z0-9_]*)\]", + r'"\1"', + sql, + flags=re.IGNORECASE, + ) + translated = re.sub( + r"\[dbo\]\.\[([A-Za-z_][A-Za-z0-9_]*)\]", + r'"\1"', + translated, + flags=re.IGNORECASE, + ) + return re.sub(r"\[([A-Za-z_][A-Za-z0-9_]*)\]", r'"\1"', translated) + + +def translate_sqlserver_select_to_sqlite(sql: str) -> str: + _reject_unsafe_sql(sql) + translated = str(sql).strip() + translated = _translate_top(translated) + translated = _translate_sqlserver_tables(translated) + translated = _quote_wferp_columns(translated) + translated = re.sub(r"\bISNULL\s*\(", "COALESCE(", translated, flags=re.IGNORECASE) + _reject_unsafe_sql(translated) + return translated diff --git a/skill_scripts/sqlite_workspace.py b/skill_scripts/sqlite_workspace.py new file mode 100644 index 0000000..996f64d --- /dev/null +++ b/skill_scripts/sqlite_workspace.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import secrets +import sqlite3 +from typing import Any + + +_IDENTIFIER_PART_RE = re.compile(r"[^a-z0-9]+") + + +def _created_at() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _now_tag() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + + +def _sanitize_identifier_part(value: str, *, default: str) -> str: + sanitized = _IDENTIFIER_PART_RE.sub("_", value.lower()).strip("_") + return sanitized or default + + +def _quote_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _sqlite_type(value: object) -> str: + if isinstance(value, bool): + return "INTEGER" + if isinstance(value, int): + return "INTEGER" + if isinstance(value, float): + return "REAL" + return "TEXT" + + +@dataclass(frozen=True) +class SQLiteRunWorkspace: + run_dir: Path + sqlite_db_path: Path + run_prefix: str + raw_table: str + enriched_table: str + manifest_path: Path + + @classmethod + def create(cls, run_dir: str | Path, *, run_id: str) -> "SQLiteRunWorkspace": + path = Path(run_dir) + sqlite_dir = path / "sqlite" + sqlite_dir.mkdir(parents=True, exist_ok=True) + + safe_run = _sanitize_identifier_part(run_id, default="run") + run_prefix = f"wferp_{_now_tag()}_{secrets.token_hex(3)}_{safe_run}" + workspace = cls( + run_dir=path, + sqlite_db_path=sqlite_dir / "wferp-report.sqlite3", + run_prefix=run_prefix, + raw_table=f"{run_prefix}_raw_ledger", + enriched_table=f"{run_prefix}_enriched_ledger", + manifest_path=sqlite_dir / f"{run_prefix}_sqlite_manifest.json", + ) + + with sqlite3.connect(workspace.sqlite_db_path): + pass + + workspace._write_manifest( + { + "sqlite_db_path": str(workspace.sqlite_db_path), + "run_prefix": workspace.run_prefix, + "raw_table": workspace.raw_table, + "lookup_tables": [], + "enriched_table": workspace.enriched_table, + "raw_row_count": 0, + "enriched_row_count": 0, + "lookup_row_counts": {}, + "ignored_lookup_rows": {}, + "cleanup_status": "active", + "retention_decision": "keep", + "created_at": _created_at(), + "residual_risks": [], + "source_workbook": None, + "source_workbook_hash": None, + "formal_db_query_hash": None, + } + ) + return workspace + + def manifest(self) -> dict[str, Any]: + return json.loads(self.manifest_path.read_text(encoding="utf-8")) + + def write_raw_rows(self, rows: list[dict[str, Any]]) -> None: + self._write_rows(self.raw_table, rows) + self._update_manifest(raw_row_count=len(rows)) + + def register_lookup_table(self, logical_name: str, rows: list[dict[str, Any]]) -> str: + logical_part = _sanitize_identifier_part(logical_name, default="lookup") + table_name = self._unique_lookup_table_name(f"{self.run_prefix}_{logical_part}") + self._write_rows(table_name, rows) + + manifest = self.manifest() + lookup_tables = list(manifest.get("lookup_tables", [])) + lookup_tables.append(table_name) + lookup_row_counts = dict(manifest.get("lookup_row_counts", {})) + lookup_row_counts[table_name] = len(rows) + self._update_manifest( + lookup_tables=lookup_tables, + lookup_row_counts=lookup_row_counts, + ) + return table_name + + def cleanup_run_tables(self) -> None: + manifest = self.manifest() + tables = [ + manifest["raw_table"], + manifest["enriched_table"], + *manifest.get("lookup_tables", []), + ] + with sqlite3.connect(self.sqlite_db_path) as conn: + for table in tables: + conn.execute(f"DROP TABLE IF EXISTS {_quote_identifier(table)}") + self._update_manifest(cleanup_status="deleted", retention_decision="delete") + + def _write_rows(self, table_name: str, rows: list[dict[str, Any]]) -> None: + if not rows: + raise ValueError(f"Cannot create {table_name} without rows") + + columns: list[str] = [] + seen_columns: set[str] = set() + for row in rows: + for column in row: + if column not in seen_columns: + seen_columns.add(column) + columns.append(column) + if not columns: + raise ValueError(f"Cannot create {table_name} without columns") + + definitions = ", ".join( + f"{_quote_identifier(column)} {_sqlite_type(next((row[column] for row in rows if column in row), None))}" + for column in columns + ) + quoted_columns = ", ".join(_quote_identifier(column) for column in columns) + bind_marks = ", ".join("?" for _ in columns) + + with sqlite3.connect(self.sqlite_db_path) as conn: + conn.execute(f"DROP TABLE IF EXISTS {_quote_identifier(table_name)}") + conn.execute(f"CREATE TABLE {_quote_identifier(table_name)} ({definitions})") + conn.executemany( + ( + f"INSERT INTO {_quote_identifier(table_name)} " + f"({quoted_columns}) VALUES ({bind_marks})" + ), + [[row.get(column) for column in columns] for row in rows], + ) + + def _unique_lookup_table_name(self, base_name: str) -> str: + manifest = self.manifest() + existing = set(manifest.get("lookup_tables", [])) + with sqlite3.connect(self.sqlite_db_path) as conn: + existing.update( + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + ) + + if base_name not in existing: + return base_name + + suffix = 2 + while f"{base_name}_{suffix}" in existing: + suffix += 1 + return f"{base_name}_{suffix}" + + def _update_manifest(self, **updates: Any) -> None: + manifest = self.manifest() + manifest.update(updates) + self._write_manifest(manifest) + + def _write_manifest(self, payload: dict[str, Any]) -> None: + self.manifest_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) diff --git a/skill_scripts/style_replay.py b/skill_scripts/style_replay.py new file mode 100644 index 0000000..4467185 --- /dev/null +++ b/skill_scripts/style_replay.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from typing import Any + +STYLE_KEYS = [ + "catalog_guardrail", + "layout_recipe", + "chart_recipe", + "table_recipe", + "interaction_recipe", + "visual_direction", + "embedded_data_policy", +] +QUERY_RESULT_KEYS = {"row_count"} + + +def _fingerprint(data: dict[str, Any]) -> str: + encoded = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def build_style_capsule(brief: dict[str, Any]) -> dict[str, Any]: + capsule = {key: _style_only_value(key, brief.get(key)) for key in STYLE_KEYS} + capsule["style_version"] = "wferp.style-capsule.v1" + capsule["style_fingerprint"] = _fingerprint({key: capsule[key] for key in STYLE_KEYS}) + return capsule + + +def _style_only_value(key: str, value: Any) -> Any: + copied = copy.deepcopy(value) + if key == "table_recipe" and isinstance(copied, list): + return [_without_query_result_keys(item) for item in copied] + return copied + + +def _without_query_result_keys(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _without_query_result_keys(item) + for key, item in value.items() + if key not in QUERY_RESULT_KEYS + } + if isinstance(value, list): + return [_without_query_result_keys(item) for item in value] + return value + + +def apply_style_capsule(capsule: dict[str, Any], *, new_prompt: str) -> dict[str, Any]: + replayed = {key: copy.deepcopy(capsule[key]) for key in STYLE_KEYS if key in capsule} + replayed["prompt"] = new_prompt + replayed["style_fingerprint"] = capsule["style_fingerprint"] + replayed["style_version"] = capsule["style_version"] + return replayed + + +def detect_replay_adjustments(capsule: dict[str, Any], *, new_columns: list[str]) -> dict[str, Any]: + column_set = set(new_columns) + incompatible: list[str] = [] + for chart in capsule.get("chart_recipe", []): + required = set(chart.get("required_columns", [])) + if required and not required.issubset(column_set): + incompatible.append(chart["id"]) + return { + "requires_checkpoint": bool(incompatible), + "incompatible_charts": incompatible, + "suggested_replacements": [{"id": chart_id, "type": "bar"} for chart_id in incompatible], + } diff --git a/skill_scripts/user_step_payload.py b/skill_scripts/user_step_payload.py new file mode 100644 index 0000000..e773013 --- /dev/null +++ b/skill_scripts/user_step_payload.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from skill_scripts.harness_state_machine import USER_STEP_MAPPING +from skill_scripts.report_harness_state import load_run_state + + +def _checkpoint_payload(run_dir: Path, filename: str) -> dict[str, Any]: + path = run_dir / "checkpoints" / filename + if not path.exists(): + return {} + payload = json.loads(path.read_text(encoding="utf-8")) + nested = payload.get("payload") + return nested if isinstance(nested, dict) else {} + + +def _step_header(step: int, state: dict[str, Any]) -> dict[str, Any]: + step_definition = USER_STEP_MAPPING[step] + return { + "run_id": state.get("run_id", ""), + "prompt": state.get("prompt", ""), + "user_step": step, + "title": step_definition["title"], + "label": step_definition["label"], + "internal_phases": step_definition["phases"], + "gate_status": state.get("gate_status", {}), + "blocking_repair_request": state.get("blocking_repair_request"), + } + + +def _limit_preview_rows(preview: dict[str, Any], *, limit: int = 50) -> dict[str, Any]: + if not isinstance(preview, dict): + return {"columns": [], "sample_rows": [], "total_row_count": 0, "preview_row_limit": limit} + + result = dict(preview) + rows = result.get("sample_rows") or result.get("rows") or [] + if not isinstance(rows, list): + rows = [] + result["sample_rows"] = rows[:limit] + result["preview_row_limit"] = limit + result["total_row_count"] = int(result.get("row_count") or len(rows)) + return result + + +def _step_1(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]: + classification = state.get("column_classification") + if not isinstance(classification, dict): + classification = _checkpoint_payload(run_dir, "01b_field_formula_classification.json") + if not isinstance(classification, dict): + classification = {} + + return { + **_step_header(1, state), + "output_targets": classification.get("output_targets", ["html"]), + "source_inventory": classification.get("source_inventory", []), + "source_to_output_matrix": classification.get("source_to_output_matrix", []), + "formula_semantics": classification.get("formula_semantics", []), + "excel_workbook_preview": classification.get("excel_workbook_preview", {}), + "unresolved_items": classification.get("unresolved_items", []), + "technical_checkpoints": ["excel_confirmation", "field_formula_classification"], + } + + +def _step_2(run_dir: Path, state: dict[str, Any]) -> dict[str, Any]: + sql_payload = _checkpoint_payload(run_dir, "02_sql_review.json") + return { + **_step_header(2, state), + "sql": sql_payload.get("sql") or state.get("sql_candidate", ""), + "validation": sql_payload.get("validation") or state.get("sql_validation") or {}, + "db_target": sql_payload.get("db_target") or {}, + "logic_not_in_sql": sql_payload.get("logic_not_in_sql", []), + "technical_checkpoints": ["sql_review"], + } + + +def _step_3(_run_dir: Path, state: dict[str, Any]) -> dict[str, Any]: + raw_preview = state.get("raw_data_preview") + enriched_preview = state.get("enriched_data_preview") + sqlite_manifest = state.get("sqlite_manifest") + if not isinstance(raw_preview, dict): + raw_preview = {} + if not isinstance(enriched_preview, dict): + enriched_preview = {} + if not isinstance(sqlite_manifest, dict): + sqlite_manifest = {} + + return { + **_step_header(3, state), + "data_source": "current-run", + "uses_mock_data": False, + "raw_preview": _limit_preview_rows(raw_preview), + "enriched_preview": _limit_preview_rows(enriched_preview), + "sqlite_summary": { + "manifest_path": state.get("sqlite_manifest_path"), + "lookup_tables": sqlite_manifest.get("lookup_tables", []), + "lookup_row_counts": sqlite_manifest.get("lookup_row_counts", {}), + "ignored_lookup_rows": sqlite_manifest.get("ignored_lookup_rows", {}), + }, + "html_preview": state.get("visual_design_checkpoint") or {}, + "excel_workbook_preview": state.get("excel_workbook_preview") or {}, + "technical_checkpoints": ["raw_data_preview", "enriched_data_preview", "sqlite_retention"], + } + + +def _step_4(_run_dir: Path, state: dict[str, Any]) -> dict[str, Any]: + return { + **_step_header(4, state), + "delivery_status": state.get("delivery_status", "not_ready"), + "validator_results": state.get("validator_results", []), + "sqlite_retention": state.get("sqlite_retention"), + "final_html": state.get("final_html_path", ""), + "final_xlsx": state.get("final_xlsx_path", ""), + "technical_checkpoints": ["report_draft", "final_review"], + } + + +def build_user_step_payload(run_dir: str | Path, step: int) -> dict[str, Any]: + run_path = Path(run_dir) + state = load_run_state(run_path) + if step == 1: + return _step_1(run_path, state) + if step == 2: + return _step_2(run_path, state) + if step == 3: + return _step_3(run_path, state) + if step == 4: + return _step_4(run_path, state) + raise ValueError(f"Unknown user step: {step}") diff --git a/skill_scripts/validator_contracts.py b/skill_scripts/validator_contracts.py new file mode 100644 index 0000000..730258c --- /dev/null +++ b/skill_scripts/validator_contracts.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +REQUIRED_VALIDATORS = [ + "source_requirement_reviewer", + "excel_classification_reviewer", + "excel_formula_reviewer", + "schema_relationship_reviewer", + "sql_safety_reviewer", + "sqlite_enrichment_reviewer", + "data_preview_reviewer", + "report_content_reviewer", + "data_visualization_reviewer", + "visual_taste_reviewer", + "react_technical_reviewer", +] + +VALIDATOR_STATUSES = {"pass", "fail", "warning", "blocked"} + +REQUIRED_PACKET_FIELDS = [ + "role", + "status", + "reviewer_identity", + "checked_scope", + "input_artifact_paths", + "reviewed_at", + "evidence", + "findings", + "requiredFixes", + "residualRisks", +] + + +class ValidatorContractError(ValueError): + pass + + +@dataclass(frozen=True) +class ValidatorResult: + role: str + status: str + evidence: list[dict[str, Any]] + findings: list[str] + requiredFixes: list[str] + residualRisks: list[str] + + def to_dict(self) -> dict[str, Any]: + return validate_evidence_packet( + { + "role": self.role, + "status": self.status, + "reviewer_identity": {"kind": "subagent", "id": f"{self.role}-agent"}, + "checked_scope": ["run-dir"], + "input_artifact_paths": ["checkpoints/current.json"], + "reviewed_at": "1970-01-01T00:00:00Z", + "evidence": self.evidence, + "findings": self.findings, + "requiredFixes": self.requiredFixes, + "residualRisks": self.residualRisks, + } + ) + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise ValidatorContractError(message) + + +def _require_string_list(value: Any, field: str) -> None: + _require(isinstance(value, list), f"{field} must be a list") + _require(all(isinstance(item, str) for item in value), f"{field} must contain strings") + + +def _has_quantitative_data_preview_check(evidence: list[dict[str, Any]]) -> bool: + required_metrics = {"row_count", "column_count"} + for item in evidence: + metrics = item.get("metrics") + if not isinstance(metrics, dict) or not required_metrics.issubset(metrics): + continue + if all(isinstance(metrics[name], (int, float)) and not isinstance(metrics[name], bool) for name in required_metrics): + return True + return False + + +def _validate_typed_evidence(item: dict[str, Any]) -> None: + evidence_type = item.get("type") + if evidence_type is None: + return + _require(evidence_type in {"file", "metric", "inspection", "command"}, f"Unknown evidence type: {evidence_type}") + if evidence_type == "file": + _require(bool(str(item.get("path", "")).strip()), "file evidence requires path") + elif evidence_type == "metric": + _require(bool(str(item.get("name", "")).strip()), "metric evidence requires name") + _require("value" in item, "metric evidence requires value") + elif evidence_type == "inspection": + _require(bool(str(item.get("name", "")).strip()), "inspection evidence requires name") + _require(bool(str(item.get("status", "")).strip()), "inspection evidence requires status") + elif evidence_type == "command": + _require(bool(str(item.get("command", "")).strip()), "command evidence requires command") + + +def _has_metric(evidence: list[dict[str, Any]], name: str) -> bool: + return _metric_value(evidence, name) is not None + + +def _metric_value(evidence: list[dict[str, Any]], name: str) -> Any: + for item in evidence: + if item.get("type") == "metric" and item.get("name") == name and "value" in item: + return item["value"] + metrics = item.get("metrics") + if isinstance(metrics, dict) and name in metrics: + return metrics[name] + return None + + +def _has_non_negative_numeric_metric(evidence: list[dict[str, Any]], name: str) -> bool: + value = _metric_value(evidence, name) + return isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0 + + +def _has_file_evidence(evidence: list[dict[str, Any]], *, required_name_part: str) -> bool: + for item in evidence: + if item.get("type") != "file": + continue + path = str(item.get("path", "")) + if path.endswith(".json") and required_name_part in path: + return True + return False + + +def _has_inspection(evidence: list[dict[str, Any]], name: str) -> bool: + for item in evidence: + if item.get("type") == "inspection" and item.get("name") == name: + return bool(str(item.get("status", "")).strip()) + return False + + +def _has_manifest_file(evidence: list[dict[str, Any]]) -> bool: + return _has_file_evidence(evidence, required_name_part="wferp_run_sqlite_manifest") + + +def validate_evidence_packet(packet: dict[str, Any]) -> dict[str, Any]: + for field in REQUIRED_PACKET_FIELDS: + _require(field in packet, f"Evidence packet missing required field: {field}") + + _require(packet["role"] in REQUIRED_VALIDATORS, f"Unknown validator role: {packet['role']}") + _require(packet["status"] in VALIDATOR_STATUSES, "Invalid validator status") + reviewer_identity = packet["reviewer_identity"] + _require(isinstance(reviewer_identity, dict), "reviewer_identity must be an object") + _require(bool(str(reviewer_identity.get("kind", "")).strip()), "reviewer_identity.kind is required") + _require(bool(str(reviewer_identity.get("id", "")).strip()), "reviewer_identity.id is required") + _require_string_list(packet["checked_scope"], "checked_scope") + _require(packet["checked_scope"], "checked_scope is required") + _require_string_list(packet["input_artifact_paths"], "input_artifact_paths") + _require(packet["input_artifact_paths"], "input_artifact_paths is required") + _require(isinstance(packet["reviewed_at"], str) and bool(packet["reviewed_at"].strip()), "reviewed_at is required") + _require(isinstance(packet["evidence"], list), "evidence must be a list") + _require(all(isinstance(item, dict) for item in packet["evidence"]), "evidence must contain objects") + for item in packet["evidence"]: + _validate_typed_evidence(item) + _require_string_list(packet["findings"], "findings") + _require_string_list(packet["requiredFixes"], "requiredFixes") + _require_string_list(packet["residualRisks"], "residualRisks") + + if packet["status"] in {"fail", "blocked"}: + _require(packet["findings"], "failed or blocked validators require findings") + _require(packet["requiredFixes"], "failed or blocked validators require requiredFixes") + + if packet["role"] == "data_preview_reviewer": + _require( + _has_quantitative_data_preview_check(packet["evidence"]), + "data_preview_reviewer requires quantitative row_count and column_count checks", + ) + if packet["role"] == "excel_classification_reviewer": + _require( + _has_file_evidence(packet["evidence"], required_name_part="classification"), + "excel_classification_reviewer requires classification json file evidence", + ) + for metric in ( + "classified_columns", + "db_field_count", + "formula_field_count", + "lookup_field_count", + "manual_only_count", + ): + _require( + _has_non_negative_numeric_metric(packet["evidence"], metric), + f"excel_classification_reviewer requires non-negative numeric {metric} metric", + ) + _require( + _has_inspection(packet["evidence"], "metadata_readability"), + "excel_classification_reviewer requires metadata_readability inspection evidence", + ) + if packet["role"] == "sqlite_enrichment_reviewer": + _require( + _has_manifest_file(packet["evidence"]), + "sqlite_enrichment_reviewer requires sqlite manifest file evidence", + ) + for metric in ("raw_row_count", "enriched_row_count", "ignored_lookup_rows"): + _require( + _has_non_negative_numeric_metric(packet["evidence"], metric), + f"sqlite_enrichment_reviewer requires non-negative numeric {metric} metric", + ) + return {"valid": True, **packet} + + +def build_final_review_gate( + packets: list[dict[str, Any]], + *, + explicit_user_acceptance: bool = False, + accepted_residual_risks: list[str] | None = None, +) -> dict[str, Any]: + validated = [validate_evidence_packet(packet) for packet in packets] + by_role: dict[str, dict[str, Any]] = {} + duplicate_roles: list[str] = [] + for packet in validated: + role = packet["role"] + if role in by_role: + duplicate_roles.append(role) + by_role[role] = packet + _require(not duplicate_roles, "Duplicate validator evidence: " + ", ".join(duplicate_roles)) + + missing_roles = [role for role in REQUIRED_VALIDATORS if role not in by_role] + non_pass_roles = [ + role + for role in REQUIRED_VALIDATORS + if role in by_role and by_role[role]["status"] != "pass" + ] + if accepted_residual_risks is None: + accepted = [] + else: + _require(isinstance(accepted_residual_risks, list), "accepted_residual_risks must be a list") + _require( + all(isinstance(item, str) for item in accepted_residual_risks), + "accepted_residual_risks must contain strings", + ) + accepted = list(accepted_residual_risks) + + accepted_set = set(accepted) + blocking = list(missing_roles) + for role in non_pass_roles: + residual_risks = by_role[role]["residualRisks"] + role_accepted = explicit_user_acceptance and any( + f"{role}: {risk}" in accepted_set for risk in residual_risks + ) + if not role_accepted: + blocking.append(role) + + return { + "allowed": not blocking, + "blocking_validators": blocking, + "accepted_residual_risks": accepted, + } diff --git a/skill_scripts/visual_checkpoint.py b/skill_scripts/visual_checkpoint.py new file mode 100644 index 0000000..e7d41c3 --- /dev/null +++ b/skill_scripts/visual_checkpoint.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from html import escape +from typing import Any, Mapping + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def _columns(package: Mapping[str, Any]) -> list[str]: + data_profile = _mapping(package.get("data_profile")) + columns = _string_list(data_profile.get("columns")) + if columns: + return columns + datasets = _mapping(package.get("datasets")) + return _string_list(datasets.get("columns")) + + +def _kpis(package: Mapping[str, Any]) -> list[dict[str, Any]]: + aggregates = _mapping(package.get("aggregates")) + return [ + {"label": str(label), "value": value} + for label, value in aggregates.items() + if isinstance(value, (int, float, str)) + ] + + +def build_visual_checkpoint_payload( + brief: Mapping[str, Any], + package: Mapping[str, Any], +) -> dict[str, Any]: + columns = _columns(package) + data_profile = dict(_mapping(package.get("data_profile"))) + if columns: + data_profile["columns"] = columns + + charts = [] + for chart in brief.get("chart_recipe", []): + if not isinstance(chart, Mapping): + continue + charts.append( + { + "id": str(chart.get("id") or ""), + "type": str(chart.get("type") or ""), + "purpose": str(chart.get("purpose") or ""), + } + ) + + tables = [] + for table in brief.get("table_recipe", []): + if not isinstance(table, Mapping): + continue + tables.append( + { + "id": str(table.get("id") or ""), + "type": str(table.get("type") or ""), + "columns": columns, + "features": _string_list(table.get("features")), + "row_count": table.get("row_count", data_profile.get("row_count", 0)), + } + ) + + layout_recipe = _mapping(brief.get("layout_recipe")) + interaction_recipe = _mapping(brief.get("interaction_recipe")) + return { + "title": "費用分析視覺設計確認", + "catalog_guardrail": str(brief.get("catalog_guardrail") or package.get("catalog_guardrail") or ""), + "layout": { + "mode": str(layout_recipe.get("mode") or ""), + "sections": _string_list(layout_recipe.get("sections")), + "density": str(layout_recipe.get("density") or ""), + }, + "kpis": _kpis(package), + "charts": charts, + "tables": tables, + "interactions": { + "filters": _string_list(interaction_recipe.get("filters")), + "drilldowns": _string_list(interaction_recipe.get("drilldowns")), + }, + "visual_direction": dict(_mapping(brief.get("visual_direction"))), + "data_profile": data_profile, + } + + +def _render_list(items: list[str]) -> str: + if not items: + return "
  • " + return "".join(f"
  • {escape(item)}
  • " for item in items) + + +def render_visual_checkpoint_html(payload: Mapping[str, Any]) -> str: + title = escape(str(payload.get("title") or "費用分析視覺設計確認")) + layout = _mapping(payload.get("layout")) + data_profile = _mapping(payload.get("data_profile")) + visual_direction = _mapping(payload.get("visual_direction")) + + kpi_cards = "".join( + f"
    {escape(str(kpi.get('label', '')))}{escape(str(kpi.get('value', '')))}
    " + for kpi in payload.get("kpis", []) + if isinstance(kpi, Mapping) + ) + chart_items = "".join( + "
  • " + f"{escape(str(chart.get('type', '')))}" + f"{escape(str(chart.get('purpose', '')))}" + "
  • " + for chart in payload.get("charts", []) + if isinstance(chart, Mapping) + ) + table_items = "".join( + "
  • " + f"{escape(str(table.get('id', '')))}" + f"{escape(', '.join(_string_list(table.get('columns'))))}" + "
  • " + for table in payload.get("tables", []) + if isinstance(table, Mapping) + ) + interactions = _mapping(payload.get("interactions")) + emphasis = _string_list(visual_direction.get("emphasis")) + + return f""" + + + + {title} + + + +
    +

    {title}

    +
    +

    版面

    +

    {escape(str(layout.get("mode", "")))} / {escape(str(layout.get("density", "")))}

    +
      {_render_list(_string_list(layout.get("sections")))}
    +
    +
    +

    KPI

    +
    {kpi_cards}
    +
    +
    +

    圖表目的

    +
      {chart_items}
    +
    +
    +

    表格

    +
      {table_items}
    +
    +
    +

    互動

    +

    篩選

    +
      {_render_list(_string_list(interactions.get("filters")))}
    +

    下鑽

    +
      {_render_list(_string_list(interactions.get("drilldowns")))}
    +
    +
    +

    視覺方向

    +

    {escape(str(visual_direction.get("tone", "")))}

    +
      {_render_list(emphasis)}
    +
    +
    +

    資料輪廓

    +

    rows: {escape(str(data_profile.get("row_count", 0)))}

    +

    columns: {escape(", ".join(_string_list(data_profile.get("columns"))))}

    +
    +
    + +""" diff --git a/skill_scripts/workbook_classifier.py b/skill_scripts/workbook_classifier.py new file mode 100644 index 0000000..5eddce5 --- /dev/null +++ b/skill_scripts/workbook_classifier.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + + +def _column_name(index: int) -> str: + name = "" + while index: + index, remainder = divmod(index - 1, 26) + name = chr(65 + remainder) + name + return name + + +def _lookup_sheet(formula: str) -> str: + match = re.search( + r"VLOOKUP\s*\([^,]+,\s*(?:'([^']+)'|([^!,]+))!", + formula, + re.IGNORECASE, + ) + if not match: + return "" + return (match.group(1) or match.group(2) or "").strip() + + +def classify_workbook( + workbook_path: str | Path, + *, + source_dir: str | Path, + primary_sheet: str = "", +) -> dict[str, Any]: + raise RuntimeError("RULE_FALLBACK_REMOVED_USE_LLM_TABLE_FIRST_CLASSIFIER") diff --git a/skill_scripts/workbook_lookup_importer.py b/skill_scripts/workbook_lookup_importer.py new file mode 100644 index 0000000..fa2f389 --- /dev/null +++ b/skill_scripts/workbook_lookup_importer.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from skill_scripts.excel_intake import _read_xlsx_sheets_with_metadata +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace + + +META_KEYS = {"", "科目編號", "公司別", "加總 - 換算台幣", "本月匯率"} + + +def _cell_text(value: object) -> str: + return "" if value is None else str(value).strip() + + +def _column_index(column: str) -> int: + value = 0 + for char in column.strip().upper(): + if not "A" <= char <= "Z": + raise ValueError(f"Invalid Excel column letter: {column}") + value = value * 26 + (ord(char) - 64) + if value == 0: + raise ValueError("Excel column letter cannot be empty") + return value - 1 + + +def _row_value(row: list[str], index: int) -> str: + return _cell_text(row[index] if index < len(row) else "") + + +def _file_hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _write_manifest(workspace: SQLiteRunWorkspace, manifest: dict[str, Any]) -> None: + workspace.manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def import_lookup_sheet( + workbook_path: str | Path, + workspace: SQLiteRunWorkspace, + *, + sheet_name: str, + logical_name: str, + key_column: str, + value_columns: dict[str, str], +) -> dict[str, Any]: + path = Path(workbook_path) + sheets = _read_xlsx_sheets_with_metadata(path) + if sheet_name not in sheets: + raise ValueError(f"Workbook sheet not found: {sheet_name}") + + key_index = _column_index(key_column) + value_indexes = { + output_name: _column_index(column) + for output_name, column in value_columns.items() + } + imported_rows: list[dict[str, Any]] = [] + ignored_rows: list[dict[str, Any]] = [] + + for row_number, row in enumerate(sheets[sheet_name].rows, start=1): + key = _row_value(row, key_index) + if key in META_KEYS or key.startswith("加總"): + ignored_rows.append( + { + "row_number": row_number, + "key": key, + "reason": "header_or_metadata", + } + ) + continue + + record = {"account_code": key} + for output_name, value_index in value_indexes.items(): + record[output_name] = _row_value(row, value_index) + + if not any(value for field, value in record.items() if field != "account_code"): + ignored_rows.append( + { + "row_number": row_number, + "key": key, + "reason": "blank_values", + } + ) + continue + + imported_rows.append(record) + + table_name = workspace.register_lookup_table(logical_name, imported_rows) + manifest = workspace.manifest() + ignored_lookup_rows = dict(manifest.get("ignored_lookup_rows", {})) + ignored_lookup_rows[table_name] = ignored_rows + manifest["ignored_lookup_rows"] = ignored_lookup_rows + manifest["source_workbook"] = str(path) + manifest["source_workbook_hash"] = _file_hash(path) + _write_manifest(workspace, manifest) + + return { + "table_name": table_name, + "sheet_name": sheet_name, + "imported_row_count": len(imported_rows), + "ignored_row_count": len(ignored_rows), + "ignored_rows": ignored_rows, + } diff --git a/skills/wferp-report.backup-20260620001512/README.md b/skills/wferp-report.backup-20260620001512/README.md new file mode 100644 index 0000000..2e457ba --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/README.md @@ -0,0 +1,11 @@ +# wferp-report + +This skill helps Codex build Workflow ERP management reports from prompts and Excel inputs. + +Core rules: +- Treat configured WFERP database connections as production. +- Execute only SELECT statements against the production database. +- Never run INSERT, UPDATE, DELETE, MERGE, TRUNCATE, DROP, ALTER, CREATE, EXEC, or stored procedures against production. +- Write extracted data only to local run artifacts such as CSV, SQLite, JSON, and HTML. + +Use SKILL.md as the entry point. Load reference files only when the active task needs them. diff --git a/skills/wferp-report.backup-20260620001512/SKILL.md b/skills/wferp-report.backup-20260620001512/SKILL.md new file mode 100644 index 0000000..dfaae41 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/SKILL.md @@ -0,0 +1,445 @@ +--- +name: wferp-report +description: "Use when the user needs a WFERP / Workflow ERP management report from a prompt, Excel workbook, ODC connection, or existing SQL evidence. Build the report through a small harness: intake -> source and Excel extraction -> normalized report plan -> field/formula checkpoint -> SELECT-only SQL review -> confirmed production read -> local SQLite enrichment -> data preview checkpoint -> report build -> final review -> single-file HTML delivery. Production database access is strictly read-only: only SELECT statements are allowed against css04 / CHD or any configured WFERP production connection." +--- + +# WFERP Report + +## Purpose + +Turn WFERP business requirements, Excel workbooks, and production read-only data into an auditable management report. The final deliverable is normally a single HTML report plus local evidence files: source inventory, field mapping, SELECT SQL, row counts, local SQLite database, report payload, validation results, and residual risks. + +This skill follows a small harness style: clear phases, hard checkpoints, file-backed decisions, and repair after review. Do not rely only on chat memory. Record every important decision and evidence item in the run folder. + +## Boundary + +Use this skill for: +- WFERP / Workflow ERP management reports. +- Excel-backed report reconstruction and analysis. +- ODC or workbook connection inspection. +- Natural-language report requests that need SELECT SQL and local report artifacts. +- Single-file HTML reports with charts, tables, evidence, and validation notes. + +Do not use this skill for: +- Production data maintenance or mutation. +- Generic web apps or dashboards with no WFERP data evidence. +- SQL-only tasks that do not need report planning or delivery artifacts. +- Any request that requires non-SELECT SQL against production. + +If the user asks for mutation, stop and ask for a verified non-production environment. Do not improvise a workaround. + +--- + +## Production Database Safety + +The current workstation database connection is production unless the user provides a verified test connection. + +Known production baseline: +- Data Source: `css04` +- Initial Catalog: `CHD` +- Provider: `SQLOLEDB.1` +- Common source object: `[CHD].[dbo].[View_Customer]` +- Reference file: `references/db-config.md` + +Allowed against production: +- SELECT queries. +- SELECT TOP smoke tests. +- SELECT-only schema probes such as `SELECT TOP 0 * FROM ...`. + +Forbidden against production: +- INSERT, UPDATE, DELETE, MERGE, TRUNCATE. +- DROP, ALTER, CREATE. +- EXEC or stored procedure calls. +- Transactional mutation or side-effect statements. +- Any script that builds or executes non-SELECT SQL against production. + +Local artifacts may be written under the run folder, including CSV, SQLite, JSON, and HTML. Label those writes as local only. + +--- + +## Harness Overview + +```text +Phase 0 Intake and Boundary Check + v +Phase 1 Source and Excel Extraction -> source inventory + workbook evidence + v +Phase 2 Normalized Report Planning -> report plan + field mapping + v +Checkpoint 1 Field, Formula, and Report Logic Confirmation (must stop) + v +Phase 3 SELECT SQL Draft and Safety Review -> sql review evidence, no DB execution yet + v +Checkpoint 2 SQL Execution Confirmation (must stop) + v +Phase 4 Confirmed SELECT Execution -> CSV + local SQLite + execution evidence + v +Checkpoint 3 Data Preview and Design Confirmation (must stop) + v +Phase 5 Report Build -> payload + scaffold + HTML draft + v +Phase 6 Final Review -> content, data, visual, technical review + v +Phase 7 Repair -> smallest necessary fixes + v +Phase 8 Delivery -> single HTML + evidence summary +``` + +Hard rule: Checkpoints 1, 2, and 3 require explicit user confirmation unless the user has already given a direct instruction that covers that exact phase. Do not bundle independent decisions into one vague yes/no question. + +--- + +## Run Workspace + +Create one timestamped run folder for every report request: + +```text +wferp-report-runs// + source/ + source-inventory.json + workbook-fields.json + workbook-connections.json + extraction-notes.md + plan/ + normalized-report-plan.json + report-plan.md + checkpoints/ + 01_field_formula.json + 02_sql_review.json + 03_data_preview.json + sql/ + extract.sql + sql-safety.json + data/ + raw.csv + raw-preview.json + aggregate-checks.json + sqlite/ + report.sqlite3 + manifest.json + db/ + db-config.json + query-execution.json + report/ + payload/ + report-payload.json + scaffold/ + delivery/ + report.html + review/ + validators/ + final-review.md + repair-log.md +``` + +Only create files that are relevant to the actual run, but keep names stable when the phase exists. + +--- + +## Quality Protocol + +Use review at phase boundaries. Keep review lightweight where the main agent already has hot context, and stronger where independent verification matters. + +| Node | Review method | Artifact | Gate | +|---|---|---|---| +| Phase 1 Source | Main-agent checklist | `source/extraction-notes.md` | Required before planning | +| Phase 2 Plan | Main-agent checklist | `checkpoints/01_field_formula.json` | Required before SQL | +| Phase 3 SQL | SQL safety validator | `checkpoints/02_sql_review.json` and `sql/sql-safety.json` | Required before DB execution | +| Phase 4 DB read | Execution validator | `db/query-execution.json` | Required before data preview | +| Phase 4 SQLite | Local data validator | `sqlite/manifest.json` | Required before report build | +| Phase 5 Report | Payload and content validator | `review/validators/report-payload.json` | Required before final review | +| Phase 6 Final | Content, data, visual, technical review | `review/final-review.md` | Required before delivery | + +A `fail` or `blocked` status stops downstream work until repaired. Fix the artifact first, then report what changed. + +--- + +## Decision Collection Rules + +At each checkpoint: +- List each decision separately. +- State the recommended option and why. +- Give the user a chance to override. +- Wait for the required answer before moving to the next phase. + +Never say "I already chose X; tell me if wrong" for required decisions. Recommendation is allowed; silent choice is not. + +Required checkpoint decisions: + +Checkpoint 1, before SQL generation: +- Confirm interpreted workbook fields and formulas. +- Confirm report objective and metrics. +- Confirm dimensions, filters, and date logic. +- Confirm unresolved assumptions or manual exceptions. + +Checkpoint 2, before production DB execution: +- Confirm the exact SELECT SQL to execute. +- Confirm source object and row/column scope. +- Confirm smoke test or preview limit, if any. +- Confirm that only SELECT will be executed. + +Checkpoint 3, before report build: +- Confirm data preview and row counts. +- Confirm report layout and chart plan. +- Confirm local SQLite enrichment views. +- Confirm any residual risk or missing field. + +--- + +## Phase 0 - Intake and Boundary Check + +Capture: +- User objective. +- Attached files and paths. +- Expected report language. +- Report audience. +- Required measures, dimensions, filters, and date range. +- Whether production DB execution is requested or only SQL is needed. +- Whether the user has already authorized SELECT smoke tests or extraction. + +Read `references/harness.md` and `references/db-config.md` when setting up the run. + +If the request is missing required files or the user wants an app instead of a report, ask a focused question and stop. + +--- + +## Phase 1 - Source and Excel Extraction + +When an Excel workbook or ODC file is provided: +- Copy the workbook to the run folder if Excel has it locked. +- Extract workbook connections, pivot cache fields, table definitions, formulas, filters, and named ranges. +- Preserve exact workbook field names. +- Classify fields as dimension, date, measure, identifier, formula, lookup, manual, or unknown. +- Record low-confidence extraction notes. + +Read as needed: +- `references/excel-intake.md` +- `references/field-classification.md` +- `references/schema-context.md` + +Output: +- `source/source-inventory.json` +- `source/workbook-fields.json` +- `source/workbook-connections.json` +- `source/extraction-notes.md` + +--- + +## Phase 2 - Normalized Report Planning + +Produce a normalized plan before writing SQL. + +Required plan sections: +- Objective and audience. +- Source files and source objects. +- Measures and dimensions. +- Workbook formula semantics. +- Filters and date logic. +- SQL extraction strategy. +- Local SQLite enrichment plan. +- Report sections, charts, and tables. +- Validators and acceptance criteria. +- Assumptions and open questions. + +Output: +- `plan/normalized-report-plan.json` +- `plan/report-plan.md` +- `checkpoints/01_field_formula.json` + +Read as needed: +- `references/report-plan-template.md` +- `references/checkpoint-payload-schema.md` +- `references/report-payload-schema.md` + +Then stop at Checkpoint 1. + +--- + +## Checkpoint 1 - Field, Formula, and Report Logic + +Before SQL generation, show the user: +- Interpreted workbook fields. +- Measures, dimensions, and filters. +- Formula or pivot semantics. +- Proposed report sections and charts. +- Open assumptions. + +Wait for confirmation. After confirmation, update `checkpoints/01_field_formula.json` and continue. + +--- + +## Phase 3 - SELECT SQL Draft and Safety Review + +Generate the smallest useful production SELECT query. + +Rules: +- Use bracketed SQL Server identifiers. +- Prefer explicit column lists. +- Avoid mutation, temp-table mutation, procedures, and side effects. +- Save SQL before execution. +- Validate forbidden tokens before asking for execution confirmation. + +Output: +- `sql/extract.sql` +- `sql/sql-safety.json` +- `checkpoints/02_sql_review.json` + +Read as needed: +- `references/sql-safety.md` +- `references/schema-context.md` + +Then stop at Checkpoint 2. + +--- + +## Checkpoint 2 - SQL Execution Confirmation + +Before production DB execution, show: +- Exact SELECT SQL path and summary. +- Source object. +- Selected columns. +- Expected row scope. +- Safety validation result. + +Wait for the user to confirm execution. If the user does not confirm, deliver the SQL and mark execution as not performed. + +--- + +## Phase 4 - Confirmed SELECT Execution and Local SQLite + +After confirmation: +- Execute only SELECT statements against production. +- Capture row count, column list, duration, and any error. +- Export rows to local CSV. +- Import rows into local SQLite. +- Create local SQLite views for report sections when useful. +- Verify CSV row count equals SQLite table row count. + +Output: +- `data/raw.csv` +- `data/raw-preview.json` +- `data/aggregate-checks.json` +- `db/query-execution.json` +- `sqlite/report.sqlite3` +- `sqlite/manifest.json` +- `checkpoints/03_data_preview.json` + +Read as needed: +- `references/sqlite-enrichment.md` +- `references/validators.md` + +Then stop at Checkpoint 3. + +--- + +## Checkpoint 3 - Data Preview and Design Confirmation + +Before report build, show: +- Row count and column count. +- Raw preview and aggregate checks. +- Local SQLite views. +- Proposed report sections, charts, and tables. +- Residual risks. + +Wait for confirmation before building the final report. + +--- + +## Phase 5 - Report Build + +Build the report from local payload data only. The browser report must not connect to production DB. + +Steps: +- Create `report/payload/report-payload.json`. +- Use `assets/scaffold-template/` only as a starting point when a React report is useful. +- Build sections for KPI summary, trends, rankings, detail tables, and validation notes. +- Use operational report design: dense, calm, readable, and evidence-backed. +- Save the draft under `report/delivery/`. + +Read as needed: +- `references/scaffold.md` +- `references/section-build.md` +- `references/component-policy.md` +- `references/rawblock-policy.md` +- `references/html-output.md` +- `report_designs/index.json` +- selected `report_designs/*.md` + +--- + +## Phase 6 - Final Review + +Review from four angles: +- Content: objective, metrics, labels, and business meaning. +- Data: row counts, aggregates, source fields, and residual risks. +- Visual: readability, chart choice, layout, and mobile behavior. +- Technical: single-file HTML behavior, no production calls, payload validity. + +Output: +- `review/final-review.md` +- validator JSON files under `review/validators/` + +--- + +## Phase 7 - Repair + +Repair the smallest broken artifact: +- If SQL is unsafe, fix SQL and repeat safety validation before any DB execution. +- If extraction is wrong, fix source extraction and update the plan. +- If data is inconsistent, fix local import or aggregates and update evidence. +- If the report is unclear, fix the affected section only. + +Write `review/repair-log.md` only when repairs were made. + +--- + +## Phase 8 - Delivery + +Deliver: +- Single-file HTML report path. +- Report payload path. +- SQL path and SQL safety evidence. +- Data row counts and SQLite manifest, if execution was performed. +- Validator summary. +- Residual risks. + +Do not claim DB execution occurred unless `db/query-execution.json` exists and records the SELECT execution evidence. + +--- + +## Progressive Reference Loading + +Load only the files needed for the active phase. + +| Phase | Read | +|---|---| +| 0 | `references/harness.md`, `references/db-config.md` | +| 1 | `references/excel-intake.md`, `references/field-classification.md`, `references/schema-context.md` | +| 2 | `references/report-plan-template.md`, `references/checkpoint-payload-schema.md`, `references/report-payload-schema.md` | +| 3 | `references/sql-safety.md`, `references/schema-context.md` | +| 4 | `references/sqlite-enrichment.md`, `references/validators.md` | +| 5 | `references/scaffold.md`, `references/section-build.md`, `references/component-policy.md`, `references/rawblock-policy.md`, selected report design | +| 6-8 | `references/review-checklist.md`, `references/repair-policy.md`, `references/html-output.md`, `references/single-html-export.md` | + +--- + +## Defaults + +- Final report format: single-file HTML. +- Database mode: production read-only, SELECT only. +- Smoke test: `SELECT TOP 1 1 AS connection_test` only after user approval or explicit instruction. +- Report style: operational management report, not a marketing page. +- Local data store: SQLite under the run folder. +- Language: follow the user request; otherwise use the workbook/report language. + +--- + +## Success Standards + +- The report is traceable from user request and Excel fields to SQL, local data, payload, and HTML. +- Production SQL is SELECT only and validated before execution. +- DB execution evidence is truthful and saved. +- Local SQLite row counts match extracted data. +- The final HTML opens offline and uses local payload data only. +- Business labels, measures, dimensions, and filters match the confirmed plan. +- Residual risks are visible instead of hidden. diff --git a/skills/wferp-report.backup-20260620001512/assets/sample-expense-analysis-prompt.md b/skills/wferp-report.backup-20260620001512/assets/sample-expense-analysis-prompt.md new file mode 100644 index 0000000..b76adab --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/sample-expense-analysis-prompt.md @@ -0,0 +1,11 @@ +Create a WFERP expense analysis report from the attached Excel workbook. + +Required output: +- Read workbook fields, formulas, filters, and pivot configuration. +- Generate read-only SELECT SQL for source data. +- Load the result into a local SQLite database. +- Build a single-file HTML report with summary metrics, charts, and validation notes. + +Database safety: +- Production database access is read-only. +- Smoke tests and extraction queries must be SELECT only. diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/index.html b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/index.html new file mode 100644 index 0000000..a6b38ea --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/index.html @@ -0,0 +1,12 @@ + + + + + + WFERP Report + + +
    + + + diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/package.json b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/package.json new file mode 100644 index 0000000..4f772bc --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/package.json @@ -0,0 +1,18 @@ +{ + "name": "wferp-report-scaffold", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0", + "typescript": "^5.8.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": {} +} diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/Report.tsx b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/Report.tsx new file mode 100644 index 0000000..677e400 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/Report.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +type ReportSection = { + id: string; + title: string; + body?: string; + data_refs?: string[]; +}; + +type ValidatorEvidence = { + validator: string; + status: string; +}; + +type ReportPayload = { + title?: string; + summary?: string; + sections?: ReportSection[]; + validator_evidence?: ValidatorEvidence[]; +}; + +const payload: ReportPayload = { + title: "WFERP Report", + summary: "Replace this scaffold payload with the generated report payload JSON.", + sections: [], + validator_evidence: [] +}; + +function Report() { + return ( +
    +

    {payload.title}

    + {payload.summary ?

    {payload.summary}

    : null} + {(payload.sections ?? []).map((section) => ( +
    +

    {section.title}

    + {section.body ?

    {section.body}

    : null} + {section.data_refs?.length ? Data refs: {section.data_refs.join(", ")} : null} +
    + ))} +
    + ); +} + +createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/components/.gitkeep b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/components/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/components/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/payload/.gitkeep b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/payload/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/payload/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/raw-blocks/.gitkeep b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/raw-blocks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/raw-blocks/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/sections/.gitkeep b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/sections/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/report/sections/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/tsconfig.json b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/tsconfig.json new file mode 100644 index 0000000..7194209 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "strict": true, + "target": "ES2022", + "types": ["vite/client"] + }, + "include": ["report/**/*.ts", "report/**/*.tsx", "report/**/*.json"] +} diff --git a/skills/wferp-report.backup-20260620001512/assets/scaffold-template/vite.config.ts b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/vite.config.ts new file mode 100644 index 0000000..58676f7 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/assets/scaffold-template/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/skills/wferp-report.backup-20260620001512/examples/checkpoint-excel-confirmation.json b/skills/wferp-report.backup-20260620001512/examples/checkpoint-excel-confirmation.json new file mode 100644 index 0000000..2232cd4 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/examples/checkpoint-excel-confirmation.json @@ -0,0 +1,26 @@ +{ + "checkpoint": "excel_field_formula_confirmation", + "status": "ready_for_review", + "source_files": [ + "input.xlsx" + ], + "detected_fields": [ + { + "name": "OrderDate", + "role": "dimension", + "source": "pivot_cache" + }, + { + "name": "CustomerName", + "role": "dimension", + "source": "pivot_cache" + }, + { + "name": "OrderAmount", + "role": "measure", + "source": "pivot_cache" + } + ], + "formula_notes": [], + "questions": [] +} diff --git a/skills/wferp-report.backup-20260620001512/examples/checkpoint-sql-review.json b/skills/wferp-report.backup-20260620001512/examples/checkpoint-sql-review.json new file mode 100644 index 0000000..5d4222e --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/examples/checkpoint-sql-review.json @@ -0,0 +1,11 @@ +{ + "checkpoint": "sql_review", + "status": "approved_for_select_only_execution", + "sql_kind": "SELECT", + "source_object": "[CHD].[dbo].[View_Customer]", + "forbidden_tokens_found": [], + "review_notes": [ + "Only SELECT is allowed against production." + ], + "smoke_test_sql": "SELECT TOP 1 1 AS connection_test" +} diff --git a/skills/wferp-report.backup-20260620001512/manifest.json b/skills/wferp-report.backup-20260620001512/manifest.json new file mode 100644 index 0000000..cf715c9 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/manifest.json @@ -0,0 +1,36 @@ +{ + "name": "wferp-report", + "version": "0.1.0", + "description": "Workflow ERP schema-aware LLM-driven report harness for prompt/Excel intake, read-only SQL, DB-backed data preview, React single-file HTML reports, and subagent validator gates.", + "entrypoint": "SKILL.md", + "language": "zh-TW", + "references": [ + "references/harness.md", + "references/db-config.md", + "references/excel-intake.md", + "references/field-classification.md", + "references/schema-context.md", + "references/sql-safety.md", + "references/sqlite-enrichment.md", + "references/checkpoint-payload-schema.md", + "references/report-payload-schema.md", + "references/component-policy.md", + "references/rawblock-policy.md", + "references/react-renderer.md", + "references/scaffold.md", + "references/section-build.md", + "references/report-plan-template.md", + "references/review-checklist.md", + "references/repair-policy.md", + "references/html-output.md", + "references/validators.md", + "references/e2e-expense-analysis.md", + "references/single-html-export.md", + "references/dynamic-design-brief.md", + "references/style-replay.md" + ], + "scripts": { + "validate": "scripts/validate-skill.sh", + "scaffold_report": "scripts/scaffold-report.sh" + } +} diff --git a/skills/wferp-report.backup-20260620001512/references/checkpoint-payload-schema.md b/skills/wferp-report.backup-20260620001512/references/checkpoint-payload-schema.md new file mode 100644 index 0000000..05b6444 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/checkpoint-payload-schema.md @@ -0,0 +1,16 @@ +# Checkpoint Payload Schema + +Checkpoint files are JSON documents stored under `checkpoints/`. + +Common fields: +- `checkpoint`: stable checkpoint name. +- `status`: `draft`, `ready_for_review`, `approved`, or `blocked`. +- `created_at`: ISO timestamp when available. +- `inputs`: source files or database objects used. +- `findings`: concise evidence and decisions. +- `questions`: unresolved user-facing questions. + +Required checkpoints: +- `01_field_formula.json`: Excel fields, formulas, pivots, filters, and inferred metrics. +- `02_sql_review.json`: SELECT-only SQL review and safety evidence. +- `03_data_preview.json`: row counts, sample rows, aggregates, and anomalies. diff --git a/skills/wferp-report.backup-20260620001512/references/component-policy.md b/skills/wferp-report.backup-20260620001512/references/component-policy.md new file mode 100644 index 0000000..71c9962 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/component-policy.md @@ -0,0 +1,10 @@ +# Component Policy + +Use components only when they make the report easier to verify and maintain. + +Rules: +- Keep the report self-contained when delivering HTML. +- Prefer simple React components for charts, metric cards, tables, filters, and validation evidence. +- Do not hide source evidence. Include clear report metadata and validation notes. +- Avoid decorative UI that makes operational data harder to scan. +- Keep labels short and business-focused. diff --git a/skills/wferp-report.backup-20260620001512/references/db-config.md b/skills/wferp-report.backup-20260620001512/references/db-config.md new file mode 100644 index 0000000..5c1941a --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/db-config.md @@ -0,0 +1,67 @@ +# DB Config + +Use this reference when configuring database access for `wferp-report`. + +## Production Baseline From ODC + +Source file: + +- `C:/Users/ivychi/util/test/css04 CHD View_Customer.odc` +- `C:/Users/ivychi/util/test/1 財務_訂單報表即時分析_CHD.XLSX` + +Extracted ODC values: + +- Provider: `SQLOLEDB.1` +- Authentication: Windows Integrated Security / SSPI +- Data Source: `css04` +- Initial Catalog: `CHD` +- Default object context: `[CHD].[dbo].[View_Customer]` +- ODC command type: `Table` + +The Excel workbook stores the same connection in `xl/connections.xml` under connection name `css04 CHD View_Customer`. + +Runtime environment mapping: + +```powershell +$env:DB_DRIVER = "pyodbc" +$env:DB_AUTH_MODE = "windows_domain" +$env:DB_ENV = "prod" +$env:DB_HOST = "css04" +$env:DB_PORT = "1433" +$env:DB_DATABASE = "CHD" +$env:DB_ODBC_DRIVER = "SQL Server" +``` + +When an OLE DB connection is needed on Windows, use this equivalent connection string: + +```text +Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=True;Initial Catalog=CHD;Data Source=css04;Use Procedure for Prepare=1;Auto Translate=True;Packet Size=4096;Use Encryption for Data=False;Tag with column collation when possible=False +``` + +## Safety + +- Treat this connection as production. +- Only execute `SELECT` statements. +- Never execute `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `TRUNCATE`, DDL, mutating stored procedures, or transaction-changing commands. +- Do not store passwords or secrets in repo files. The ODC uses integrated authentication and does not provide a password. +- Do not embed DB connection strings or credentials in delivered HTML reports. + +## Read-Only Smoke Test + +Use only this shape for connection smoke testing: + +```sql +SELECT TOP 1 1 AS connection_test +``` + +If this query cannot connect, report the connection-layer error. Do not try business-table queries until the smoke test succeeds. + +## Verified Runtime Note + +The production smoke test succeeds when executed from an unsandboxed Windows PowerShell process under `CHD-TECH\ivychi` with the OLE DB connection string above: + +```text +SELECT TOP 1 1 AS connection_test -> 1 +``` + +The same connection can fail inside the Codex sandbox with `[DBNETLIB][ConnectionOpen (SECCreateCredentials()).]SSL Security error.` Treat that as a sandbox/security-context failure, not as evidence that `css04` or `CHD` is unavailable. diff --git a/skills/wferp-report.backup-20260620001512/references/dynamic-design-brief.md b/skills/wferp-report.backup-20260620001512/references/dynamic-design-brief.md new file mode 100644 index 0000000..3b3464b --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/dynamic-design-brief.md @@ -0,0 +1,12 @@ +# Dynamic Design Brief + +Create a short design brief before building the report UI. + +Capture: +- Primary audience. +- Core decisions the report supports. +- Required metrics and comparisons. +- Filter and drill-down expectations. +- Validation or audit requirements. + +Use the brief to guide layout, chart choice, and labeling. diff --git a/skills/wferp-report.backup-20260620001512/references/e2e-expense-analysis.md b/skills/wferp-report.backup-20260620001512/references/e2e-expense-analysis.md new file mode 100644 index 0000000..36408e2 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/e2e-expense-analysis.md @@ -0,0 +1,17 @@ +# End-to-End Report Example + +A complete run should produce: +- `source/source-inventory.json` +- `plan/normalized-report-plan.json` +- `plan/report-plan.md` +- `checkpoints/01_field_formula.json` +- `checkpoints/02_sql_review.json` +- `checkpoints/03_data_preview.json` +- `sql/*.sql` +- `data/*.csv` and preview JSON +- `sqlite/*.sqlite3` and manifest JSON +- `report/payload/*.json` +- `report/*.html` +- `review/validators/*.json` + +The production database step must execute SELECT only. diff --git a/skills/wferp-report.backup-20260620001512/references/excel-intake.md b/skills/wferp-report.backup-20260620001512/references/excel-intake.md new file mode 100644 index 0000000..bc91b23 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/excel-intake.md @@ -0,0 +1,12 @@ +# Excel Intake + +When an Excel workbook is attached: + +1. Work from a copied file if the workbook is locked by Excel. +2. Inspect workbook XML, connections, pivot caches, tables, formulas, filters, and named ranges. +3. Extract field names exactly as Excel stores them. +4. Classify each field as dimension, measure, date, identifier, formula, or unknown. +5. Record workbook connection metadata without exposing sensitive secrets. +6. Compare workbook fields with database schema using SELECT-only metadata checks. + +For `.xlsx`, prefer structured workbook parsing over manual string search. diff --git a/skills/wferp-report.backup-20260620001512/references/field-classification.md b/skills/wferp-report.backup-20260620001512/references/field-classification.md new file mode 100644 index 0000000..ca9afe5 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/field-classification.md @@ -0,0 +1,13 @@ +# Field Classification + +Classify fields before SQL generation. + +Field roles: +- `dimension`: customer, product, salesperson, company, region, category. +- `date`: order date, shipping date, accounting date, year, month, period. +- `measure`: amount, tax, quantity, cost, price, margin. +- `identifier`: order number, customer code, product code, row id. +- `formula`: workbook-calculated value or derived report metric. +- `unknown`: field requiring user or schema confirmation. + +Prefer exact workbook names and database column names in artifacts. diff --git a/skills/wferp-report.backup-20260620001512/references/harness.md b/skills/wferp-report.backup-20260620001512/references/harness.md new file mode 100644 index 0000000..fb24751 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/harness.md @@ -0,0 +1,66 @@ +# WFERP Report Harness + +This reference expands the phase workflow in `SKILL.md`. Use it when creating or resuming a report run. + +## Operating Principle + +The harness turns a report request into auditable local artifacts. Every phase should leave evidence in the run folder. If a phase is skipped because the user directly authorized a later step, record that reason in the checkpoint or manifest. + +Production DB access remains SELECT only. + +## Phase Artifacts + +| Phase | Main artifacts | Required before next phase | +|---|---|---| +| 0 Intake | run folder, source inventory stub | DB safety boundary recorded | +| 1 Source | workbook fields, connections, extraction notes | field classification complete | +| 2 Plan | normalized plan, markdown plan, checkpoint 1 | user confirms logic | +| 3 SQL | extract.sql, sql-safety.json, checkpoint 2 | user confirms execution | +| 4 Data | raw CSV, SQLite, manifests, checkpoint 3 | user confirms preview/design | +| 5 Build | payload JSON, HTML draft | payload validates | +| 6 Review | final-review.md, validator JSON | failures repaired | +| 7 Repair | repair-log.md when needed | targeted validators rerun | +| 8 Delivery | report.html and summary | evidence paths provided | + +## Checkpoint Payloads + +Checkpoint payloads should include: +- `checkpoint`: stable checkpoint id. +- `status`: `draft`, `ready_for_review`, `approved`, `blocked`, or `skipped_with_reason`. +- `summary`: concise human-readable summary. +- `decisions_required`: independent decisions still waiting on the user. +- `evidence`: paths to supporting artifacts. +- `approved_by_user`: true only after explicit confirmation. +- `residual_risks`: known limitations. + +## SQL Gate + +Before any production execution: +1. Save SQL to `sql/extract.sql`. +2. Confirm it is a single SELECT or WITH...SELECT statement. +3. Strip comments and string literals for forbidden-token scanning. +4. Reject any DML, DDL, EXEC, procedure, transaction mutation, or side-effect statement. +5. Save `sql/sql-safety.json`. +6. Stop at Checkpoint 2 unless the user already gave exact approval for that SQL execution. + +## Data Gate + +After production SELECT execution: +1. Save raw extracted data locally. +2. Save execution metadata: user, server, database, object, row count, column count, duration, and SQL path. +3. Import to local SQLite. +4. Verify CSV row count equals SQLite table row count. +5. Save aggregate checks that support report KPIs. +6. Stop at Checkpoint 3 before building the final report. + +## Report Gate + +The report must be built from local artifacts only. It must not query production from the browser. Include validation notes so reviewers can trace the numbers. + +## Resume Rules + +When resuming a run: +- Read the latest checkpoint first. +- Read manifests before re-running extraction. +- Do not re-execute production SQL unless the user confirms again or the prior instruction clearly still applies. +- Prefer continuing from saved artifacts when they are valid. diff --git a/skills/wferp-report.backup-20260620001512/references/html-output.md b/skills/wferp-report.backup-20260620001512/references/html-output.md new file mode 100644 index 0000000..8728e00 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/html-output.md @@ -0,0 +1,10 @@ +# HTML Output + +Deliver a single HTML file when the user asks for a report artifact. + +Requirements: +- Embed needed CSS and JavaScript. +- Use local payload data already extracted into artifacts. +- Include report title, period, filters, metrics, charts, tables, and validation notes. +- Avoid remote assets unless explicitly requested. +- Keep the report usable by opening the file directly in a browser. diff --git a/skills/wferp-report.backup-20260620001512/references/rawblock-policy.md b/skills/wferp-report.backup-20260620001512/references/rawblock-policy.md new file mode 100644 index 0000000..2d7416a --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/rawblock-policy.md @@ -0,0 +1,11 @@ +# Raw Block Policy + +Raw blocks are verbatim evidence sections used for auditability. + +Allowed raw blocks: +- Final SELECT SQL. +- Source inventory. +- Validator summaries. +- Data preview snippets. + +Do not include secrets, credentials, or full connection strings with sensitive fields. Mask values if needed. Never include DML or DDL as executable instructions. diff --git a/skills/wferp-report.backup-20260620001512/references/react-renderer.md b/skills/wferp-report.backup-20260620001512/references/react-renderer.md new file mode 100644 index 0000000..79f16e3 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/react-renderer.md @@ -0,0 +1,10 @@ +# React Renderer + +Use React only when it makes the HTML report easier to build or test. + +Renderer rules: +- Read data from generated payload JSON or an embedded payload object. +- Keep visual components small and deterministic. +- Prefer tables and charts that support scanning and comparison. +- Do not fetch production data from the browser. +- The browser report must use already extracted local data. diff --git a/skills/wferp-report.backup-20260620001512/references/repair-policy.md b/skills/wferp-report.backup-20260620001512/references/repair-policy.md new file mode 100644 index 0000000..afcb2c2 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/repair-policy.md @@ -0,0 +1,10 @@ +# Repair Policy + +When an artifact is invalid or incomplete: + +1. Identify the broken artifact and the evidence. +2. Fix the smallest affected file or generation step. +3. Re-run the validator that caught the issue. +4. Record the repair in the run notes. + +Never repair a production database by mutation. Production access remains SELECT only. diff --git a/skills/wferp-report.backup-20260620001512/references/report-payload-schema.md b/skills/wferp-report.backup-20260620001512/references/report-payload-schema.md new file mode 100644 index 0000000..2c7662a --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/report-payload-schema.md @@ -0,0 +1,15 @@ +# Report Payload Schema + +The report payload is JSON consumed by the final HTML report. + +Suggested top-level fields: +- `title`: report title. +- `subtitle`: optional context line. +- `generated_at`: ISO timestamp. +- `source`: source workbook and database object metadata. +- `kpis`: list of label/value/unit items. +- `sections`: chart, table, and narrative sections. +- `validation`: evidence from row counts, SQL review, and data checks. +- `warnings`: known limitations or unresolved assumptions. + +Use stable field names so the renderer can be reused. diff --git a/skills/wferp-report.backup-20260620001512/references/report-plan-template.md b/skills/wferp-report.backup-20260620001512/references/report-plan-template.md new file mode 100644 index 0000000..78cc2f2 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/report-plan-template.md @@ -0,0 +1,27 @@ +# Report Plan Template + +Use this outline for `plan/report-plan.md`. + +``` +# Report Plan + +## Objective + +## Inputs + +## Source Fields + +## Measures + +## Dimensions + +## SQL Extraction + +## Local SQLite Model + +## Report Sections + +## Validation + +## Open Questions +``` diff --git a/skills/wferp-report.backup-20260620001512/references/review-checklist.md b/skills/wferp-report.backup-20260620001512/references/review-checklist.md new file mode 100644 index 0000000..1beaf52 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/review-checklist.md @@ -0,0 +1,10 @@ +# Review Checklist + +Before saying a report run is complete, verify: +- Workbook fields were extracted and recorded. +- Production SQL was reviewed as SELECT only. +- Smoke test and extraction evidence are saved. +- CSV and SQLite row counts match. +- Aggregates used by the report are reproducible from local SQLite. +- Final HTML is generated from local artifacts. +- Known limitations are documented. diff --git a/skills/wferp-report.backup-20260620001512/references/scaffold.md b/skills/wferp-report.backup-20260620001512/references/scaffold.md new file mode 100644 index 0000000..ca65db5 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/scaffold.md @@ -0,0 +1,11 @@ +# Scaffold + +Use `assets/scaffold-template/` as a lightweight starting point for report rendering. + +Expected flow: +1. Copy scaffold files into the run folder when a React-based report is useful. +2. Replace placeholder payload with generated report payload JSON. +3. Render charts, metrics, tables, and evidence from local data. +4. Export a single HTML file for delivery. + +Keep scaffold text clean UTF-8 and avoid environment-specific secrets. diff --git a/skills/wferp-report.backup-20260620001512/references/schema-context.md b/skills/wferp-report.backup-20260620001512/references/schema-context.md new file mode 100644 index 0000000..a71edbc --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/schema-context.md @@ -0,0 +1,13 @@ +# Schema Context + +For WFERP reports, schema context comes from: +- Excel connection metadata. +- Workbook pivot cache fields. +- SELECT-only schema probes such as `SELECT TOP 0 * FROM ...`. +- Existing repo documentation and prior run manifests. + +SQL generation rules: +- Use bracketed identifiers for SQL Server columns and objects. +- Select only required columns when possible. +- Keep source object names explicit. +- Save generated SQL under the run folder before execution. diff --git a/skills/wferp-report.backup-20260620001512/references/section-build.md b/skills/wferp-report.backup-20260620001512/references/section-build.md new file mode 100644 index 0000000..64f06dd --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/section-build.md @@ -0,0 +1,11 @@ +# Section Build + +Recommended report sections: +- Executive summary with key totals and date coverage. +- Trend view by year, month, or period. +- Customer ranking and contribution. +- Product or category ranking. +- Salesperson or department ranking. +- Data quality and validation notes. + +Keep each section tied to a local SQLite query or payload block. diff --git a/skills/wferp-report.backup-20260620001512/references/single-html-export.md b/skills/wferp-report.backup-20260620001512/references/single-html-export.md new file mode 100644 index 0000000..e1171f7 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/single-html-export.md @@ -0,0 +1,11 @@ +# Single HTML Export + +For final delivery, prefer one portable HTML file. + +Include: +- Embedded CSS. +- Embedded or bundled payload data. +- Charts and tables needed for review. +- Validation summary. + +Do not require access to the production database when opening the report. diff --git a/skills/wferp-report.backup-20260620001512/references/sql-safety.md b/skills/wferp-report.backup-20260620001512/references/sql-safety.md new file mode 100644 index 0000000..b5c43e7 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/sql-safety.md @@ -0,0 +1,16 @@ +# SQL Safety + +Production SQL Server access is read-only. + +Allowed: +- SELECT queries. +- SELECT TOP smoke tests. +- SELECT-only schema probes such as `SELECT TOP 0 * FROM object`. + +Forbidden against production: +- INSERT, UPDATE, DELETE, MERGE, TRUNCATE. +- DROP, ALTER, CREATE. +- EXEC, stored procedures, and procedure calls. +- Transactional mutation, temp-table mutation, or side-effect queries. + +Before execution, validate the statement begins with SELECT or WITH and contains no forbidden tokens outside comments or string literals. diff --git a/skills/wferp-report.backup-20260620001512/references/sqlite-enrichment.md b/skills/wferp-report.backup-20260620001512/references/sqlite-enrichment.md new file mode 100644 index 0000000..8c3b0c4 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/sqlite-enrichment.md @@ -0,0 +1,11 @@ +# SQLite Enrichment + +Use local SQLite for report shaping after production SELECT extraction. + +Allowed local operations: +- Create local tables and views. +- Import CSV data extracted from production SELECT queries. +- Build aggregate views for report sections. +- Store manifests, row counts, and validation evidence. + +Production restrictions do not prevent local SQLite writes, but artifacts must clearly identify that writes are local only. diff --git a/skills/wferp-report.backup-20260620001512/references/style-replay.md b/skills/wferp-report.backup-20260620001512/references/style-replay.md new file mode 100644 index 0000000..208fe67 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/style-replay.md @@ -0,0 +1,9 @@ +# Style Replay + +When an Excel workbook supplies visual context: +- Reuse business labels, grouping, and number formats where helpful. +- Preserve important ordering and filters. +- Convert workbook layout into a clear web report layout. +- Avoid copying broken or unreadable formatting. + +Operational reports should prioritize dense, calm, readable information. diff --git a/skills/wferp-report.backup-20260620001512/references/validators.md b/skills/wferp-report.backup-20260620001512/references/validators.md new file mode 100644 index 0000000..3a99f67 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/references/validators.md @@ -0,0 +1,13 @@ +# Validators + +Run validators before delivery. + +Recommended checks: +- `skill_encoding`: no replacement characters or known mojibake markers in skill files. +- `sql_safety`: generated production SQL is SELECT only. +- `row_count`: local SQLite row count matches extracted CSV row count. +- `schema_match`: extracted columns match expected workbook fields. +- `payload_shape`: report payload has required top-level fields. +- `html_smoke`: final HTML opens and contains report title and core sections. + +Save validator outputs as JSON under `review/validators/`. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/README.md b/skills/wferp-report.backup-20260620001512/report_designs/README.md new file mode 100644 index 0000000..34e5b64 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/README.md @@ -0,0 +1,3 @@ +# Report Designs + +Design variants for WFERP reports. Choose the variant that best matches the business question and workbook structure. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/aging-analysis.md b/skills/wferp-report.backup-20260620001512/report_designs/aging-analysis.md new file mode 100644 index 0000000..301203d --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/aging-analysis.md @@ -0,0 +1,10 @@ +# Aging Analysis Design + +Use for receivable, payable, or inventory aging. + +Sections: +- Aging bucket KPIs. +- Bucket distribution chart. +- Customer or vendor ranking. +- Overdue detail table. +- Validation summary. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/design.md b/skills/wferp-report.backup-20260620001512/report_designs/design.md new file mode 100644 index 0000000..88482eb --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/design.md @@ -0,0 +1,26 @@ +# Design Contract + +`report_designs/index.json` is the canonical allowlist. It must list exactly: + +1. `financial-control` +2. `executive-summary` +3. `detail-ledger` +4. `exception-audit` +5. `operations-review` +6. `trend-briefing` + +Each report design must define the following front matter keys: + +- `id` +- `label` +- `best_for` +- `required_sections` +- `default_components` +- `chart_policy` +- `table_policy` +- `kpi_policy` +- `tone` +- `layout_density` +- `validator_focus` + +The content after front matter should explain how the report should be structured, what evidence is expected, and which validator concerns are most important. Front matter arrays and objects should be JSON-compatible so the Python catalog loader can parse the metadata without a YAML dependency. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/detail-ledger.md b/skills/wferp-report.backup-20260620001512/report_designs/detail-ledger.md new file mode 100644 index 0000000..835ab8f --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/detail-ledger.md @@ -0,0 +1,17 @@ +--- +id: "detail-ledger" +label: "明細查詢表" +best_for: ["逐筆交易", "來源欄位確認", "明細匯出"] +required_sections: ["query-scope", "detail-table", "field-notes", "data-limitations"] +default_components: ["DataTable", "InsightBlock"] +chart_policy: {"preferred": [], "avoid": ["pie", "donut", "area"]} +table_policy: {"density": "compact", "summary_rows": true, "conditional_formatting": true} +kpi_policy: {"include_variance": false, "include_budget_ratio": false} +tone: "查核導向、逐筆、可回溯" +layout_density: "dense" +validator_focus: ["column_completeness", "row_count_consistency", "filter_transparency"] +--- + +# 明細查詢表 + +Use this design when the user primarily needs a reliable ledger-style output with source keys and minimal interpretation. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/exception-audit.md b/skills/wferp-report.backup-20260620001512/report_designs/exception-audit.md new file mode 100644 index 0000000..bb05828 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/exception-audit.md @@ -0,0 +1,17 @@ +--- +id: "exception-audit" +label: "異常稽核表" +best_for: ["缺漏檢查", "超額檢查", "控制例外"] +required_sections: ["audit-scope", "exception-rules", "exception-table", "risk-notes", "recommendations"] +default_components: ["KpiGrid", "DataTable", "InsightBlock", "RecommendationList"] +chart_policy: {"preferred": ["bar", "stacked-bar"], "avoid": ["pie"]} +table_policy: {"density": "compact", "summary_rows": true, "conditional_formatting": true} +kpi_policy: {"include_variance": true, "include_budget_ratio": false} +tone: "稽核、證據優先、風險分級" +layout_density: "dense" +validator_focus: ["rule_clarity", "source_key_presence", "residual_risk_disclosure"] +--- + +# 異常稽核表 + +Use this design when the report must prioritize exceptions and give the user a concrete audit trail for follow-up. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/executive-summary.md b/skills/wferp-report.backup-20260620001512/report_designs/executive-summary.md new file mode 100644 index 0000000..c2e449b --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/executive-summary.md @@ -0,0 +1,10 @@ +# Executive Summary Design + +Use for leadership review. + +Sections: +- KPI strip. +- Trend chart. +- Top customers or products. +- Exception notes. +- Validation summary. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/expense-analysis.md b/skills/wferp-report.backup-20260620001512/report_designs/expense-analysis.md new file mode 100644 index 0000000..dfba2b2 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/expense-analysis.md @@ -0,0 +1,11 @@ +# Expense Analysis Design + +Use for expense workbook analysis. + +Sections: +- Total expense and period coverage. +- Expense category breakdown. +- Department or owner ranking. +- Monthly trend. +- Outlier table. +- Validation summary. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/financial-control.md b/skills/wferp-report.backup-20260620001512/report_designs/financial-control.md new file mode 100644 index 0000000..b2f139f --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/financial-control.md @@ -0,0 +1,17 @@ +--- +id: "financial-control" +label: "財務控制" +best_for: ["費用分析", "預算差異", "異常控管"] +required_sections: ["executive-summary", "kpi-overview", "trend", "detail-table", "recommendations"] +default_components: ["KpiGrid", "ChartBlock", "DataTable", "InsightBlock", "RecommendationList"] +chart_policy: {"preferred": ["bar", "stacked-bar", "combo"], "avoid": ["pie"]} +table_policy: {"density": "compact", "summary_rows": true, "conditional_formatting": true} +kpi_policy: {"include_variance": true, "include_budget_ratio": true} +tone: "管理控制、明確、可追責" +layout_density: "dense" +validator_focus: ["aggregate_consistency", "variance_explanation", "exception_visibility"] +--- + +# 財務控制 + +Use this design for expense analysis, budget variance review, voucher checks, tax review, and control-oriented management reports. It should lead with verified totals and variances, keep exception rows visible, and make every recommendation traceable to source fields, SQL filters, and aggregate checks. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/index.json b/skills/wferp-report.backup-20260620001512/report_designs/index.json new file mode 100644 index 0000000..aff6c72 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/index.json @@ -0,0 +1,8 @@ +[ + "financial-control", + "executive-summary", + "detail-ledger", + "exception-audit", + "operations-review", + "trend-briefing" +] diff --git a/skills/wferp-report.backup-20260620001512/report_designs/operations-review.md b/skills/wferp-report.backup-20260620001512/report_designs/operations-review.md new file mode 100644 index 0000000..766221e --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/operations-review.md @@ -0,0 +1,17 @@ +--- +id: "operations-review" +label: "營運檢討報告" +best_for: ["部門檢討", "流程瓶頸", "採購庫存營運"] +required_sections: ["operations-summary", "kpi-overview", "ranking", "process-observations", "recommendations"] +default_components: ["KpiGrid", "ChartBlock", "DataTable", "InsightBlock", "RecommendationList"] +chart_policy: {"preferred": ["bar", "line", "stacked-bar"], "avoid": ["pie"]} +table_policy: {"density": "balanced", "summary_rows": true, "conditional_formatting": true} +kpi_policy: {"include_variance": true, "include_budget_ratio": false} +tone: "營運改善、可執行、追蹤導向" +layout_density: "balanced" +validator_focus: ["grouping_correctness", "metric_definition", "actionability"] +--- + +# 營運檢討報告 + +Use this design for repeatable operating reviews where users compare performance across departments, periods, or process stages. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/order-analysis.md b/skills/wferp-report.backup-20260620001512/report_designs/order-analysis.md new file mode 100644 index 0000000..bdf505f --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/order-analysis.md @@ -0,0 +1,11 @@ +# Order Analysis Design + +Use for order, customer, product, and salesperson analysis. + +Sections: +- Order amount and tax KPIs. +- Year or month trend. +- Customer ranking. +- Product or category ranking. +- Salesperson ranking. +- Data quality notes. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/sales-performance.md b/skills/wferp-report.backup-20260620001512/report_designs/sales-performance.md new file mode 100644 index 0000000..356d561 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/sales-performance.md @@ -0,0 +1,11 @@ +# Sales Performance Design + +Use for sales team, customer, or product performance. + +Sections: +- Sales amount and order count. +- Trend by period. +- Salesperson ranking. +- Customer contribution. +- Product mix. +- Validation summary. diff --git a/skills/wferp-report.backup-20260620001512/report_designs/trend-briefing.md b/skills/wferp-report.backup-20260620001512/report_designs/trend-briefing.md new file mode 100644 index 0000000..45dfa26 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/report_designs/trend-briefing.md @@ -0,0 +1,17 @@ +--- +id: "trend-briefing" +label: "趨勢簡報" +best_for: ["月趨勢", "季趨勢", "年度變動"] +required_sections: ["trend-summary", "period-chart", "period-table", "change-analysis", "next-observations"] +default_components: ["KpiGrid", "ChartBlock", "DataTable", "InsightBlock"] +chart_policy: {"preferred": ["line", "area", "combo"], "avoid": ["pie", "donut"]} +table_policy: {"density": "comfortable", "summary_rows": true, "conditional_formatting": true} +kpi_policy: {"include_variance": true, "include_budget_ratio": false} +tone: "趨勢解讀、期間清楚、保留假設" +layout_density: "balanced" +validator_focus: ["period_ordering", "delta_recalculation", "missing_period_disclosure"] +--- + +# 趨勢簡報 + +Use this design for management briefings where direction, acceleration, and outlier periods matter more than individual transaction rows. diff --git a/skills/wferp-report.backup-20260620001512/scripts/README.md b/skills/wferp-report.backup-20260620001512/scripts/README.md new file mode 100644 index 0000000..019a51e --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/README.md @@ -0,0 +1,9 @@ +# Scripts + +Scripts in this folder support repeatable report tasks. + +Guidelines: +- Scripts must not execute production DML or DDL. +- Scripts that query production must accept only SELECT SQL. +- Prefer explicit input and output paths. +- Save logs and manifests into the active run folder. diff --git a/skills/wferp-report.backup-20260620001512/scripts/print-expense-fixture-sql.sh b/skills/wferp-report.backup-20260620001512/scripts/print-expense-fixture-sql.sh new file mode 100644 index 0000000..f8f534b --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/print-expense-fixture-sql.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${WFERP_REPO_ROOT:-/home/timmypai/.codex/worktrees/5f5b/wferp}" +cd "${REPO_ROOT}" + +python3 -m tests.skill_scripts.expense_report_fixture diff --git a/skills/wferp-report.backup-20260620001512/scripts/run-expense-postgres-e2e.sh b/skills/wferp-report.backup-20260620001512/scripts/run-expense-postgres-e2e.sh new file mode 100644 index 0000000..f0ffad6 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/run-expense-postgres-e2e.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${WFERP_REPO_ROOT:-/home/timmypai/.codex/worktrees/5f5b/wferp}" +cd "${REPO_ROOT}" + +bash scripts/run_expense_analysis_postgres_e2e.sh diff --git a/skills/wferp-report.backup-20260620001512/scripts/run-expense-sqlite-e2e.sh b/skills/wferp-report.backup-20260620001512/scripts/run-expense-sqlite-e2e.sh new file mode 100644 index 0000000..5586923 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/run-expense-sqlite-e2e.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${WFERP_REPO_ROOT:-/home/timmypai/.codex/worktrees/5f5b/wferp}" +cd "${REPO_ROOT}" + +bash scripts/run_expense_analysis_sqlite_e2e.sh diff --git a/skills/wferp-report.backup-20260620001512/scripts/scaffold-report.sh b/skills/wferp-report.backup-20260620001512/scripts/scaffold-report.sh new file mode 100644 index 0000000..05149cf --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/scaffold-report.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +SKILL_ROOT="${WFERP_REPORT_SKILL_ROOT:-/home/timmypai/.codex/skills/wferp-report}" +DESTINATION="${1:-}" + +if [[ -z "${DESTINATION}" ]]; then + echo "usage: scaffold-report.sh " >&2 + exit 2 +fi + +mkdir -p "${DESTINATION}" +cp -R "${SKILL_ROOT}/assets/scaffold-template/." "${DESTINATION}/" +echo "scaffold created: ${DESTINATION}" diff --git a/skills/wferp-report.backup-20260620001512/scripts/validate-skill.sh b/skills/wferp-report.backup-20260620001512/scripts/validate-skill.sh new file mode 100644 index 0000000..6b03431 --- /dev/null +++ b/skills/wferp-report.backup-20260620001512/scripts/validate-skill.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="${WFERP_REPO_ROOT:-/home/timmypai/.codex/worktrees/5f5b/wferp}" +SKILL_ROOT="${WFERP_REPORT_SKILL_ROOT:-/home/timmypai/.codex/skills/wferp-report}" +cd "${REPO_ROOT}" + +python3 scripts/validate_local_wferp_report_skill.py --skill-root "${SKILL_ROOT}" diff --git a/skills/wferp-report/.editorconfig b/skills/wferp-report/.editorconfig new file mode 100644 index 0000000..d2560e9 --- /dev/null +++ b/skills/wferp-report/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{md,json,tsx,ts,html,sh}] +charset = utf-8 + diff --git a/skills/wferp-report/.vscode/settings.json b/skills/wferp-report/.vscode/settings.json new file mode 100644 index 0000000..262621e --- /dev/null +++ b/skills/wferp-report/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.encoding": "utf8", + "files.autoGuessEncoding": false, + "files.eol": "\n" +} diff --git a/skills/wferp-report/README.md b/skills/wferp-report/README.md new file mode 100644 index 0000000..a2bce26 --- /dev/null +++ b/skills/wferp-report/README.md @@ -0,0 +1,91 @@ +# wferp-report + +## 13-Phase Engine, 4-Step Visual Companion + +The technical harness still has 13 phases. The user-facing Visual Companion is simplified to 4 steps only: + +1. Source-to-Output Logic +2. SQL Query +3. Data Result and Report Design +4. Final Delivery + +This simplification is UI-only. It does not remove SQL safety, SQLite enrichment, lookup tables, validators, repair flow, state gates, Visual Companion confirmation/modify controls, or delivery evidence. + +## True Excel Output + +When Excel output is requested, generate a real `.xlsx` workbook and evidence. Use the spreadsheets skill and `@oai/artifact-tool`; do not default to `openpyxl`. The workbook export must update `state.json` with `final_xlsx_path` and `excel_workbook_evidence`. + +`wferp-report` 是 Workflow ERP / WFERP 的 LLM-driven report harness。它把使用者 prompt、uploaded files、Excel 欄位與公式、WFERP schema/relationship、read-only SQL、DB execution、SQLite enrichment、data preview、report type/design/options、React renderer、single-file HTML 與 subagent validator evidence 串成一條可驗證流程。 + +執行本 skill 就是執行 harness flow。不要只產出一段 SQL 後停止;必須建立 run、產生 checkpoint、等待 user confirmation、保存資料預覽與 validator evidence,最後才 delivery。 + +## 輸入組合 + +四種輸入組合都支援,且全部收斂成同一份 `plan/normalized-report-plan.json`: + +1. prompt 資料來源 + prompt 報表格式。 +2. prompt 資料來源 + Excel report template。 +3. Excel source + prompt 報表格式。 +4. Excel source + Excel report template。 + +Excel 可能包含 DB 欄位、lookup 對照、公式欄位、舊報表模板、人工欄位與格式欄位。Skill 需由 LLM 判斷欄位與公式語意,不要求使用者手動標註。 + +## Phase Flow + +1. Phase 0:Intake。 +2. Phase 1:Source / Excel Requirement。 +3. Phase 2:Report Planning。 +4. Phase 3:Field & Formula Checkpoint。 +5. Phase 4:SQL Review Checkpoint。 +6. Phase 5:Confirmed DB Execution。 +7. Phase 6:Data Preview Checkpoint。 +8. Phase 7:Report Selection Checkpoint。 +9. Phase 8:Final Report Scaffold。 +10. Phase 9:Section Build。 +11. Phase 10:Final Review。 +12. Phase 11:Repair。 +13. Phase 12:Delivery。 + +## 使用者 Checkpoints + +主要 checkpoint 收斂成四個,避免每個內部小步驟都打斷使用者: + +- **Field & Formula Checkpoint**:確認 uploaded files、Excel confirmation、sheet roles、DB-backed / formula-backed / lookup-backed / manual-only 欄位、WFERP schema/relationship mapping、report logic、chart/layout 初稿。 +- **SQL Review Checkpoint**:確認 read-only SQL、table/field readable names、joins、filters、aggregates、assumptions 與不下推 DB 的公式/enrichment 欄位。 +- **Data Preview Checkpoint**:確認 DB raw data preview、SQLite enrichment、lookup hit/miss、formula status、aggregate checks 與 excluded rows。 +- **Report Selection / Final Checkpoint**:確認 report type、design/options、chart/table/layout、React HTML report、Excel-like view、validator evidence、residual risks 與 SQLite retention。 + +## CLI 入口 + +```bash +python3 -m skill_scripts.cli_report_harness create-run --run-root wferp-report-runs --run-id run-001 --prompt "查詢費用分析" --llm-provider codex +python3 -m skill_scripts.cli_report_harness classify-workbook --run-dir wferp-report-runs/run-001 --input-file requirement.xlsx --llm-provider codex +python3 -m skill_scripts.cli_report_harness write-sql-review --run-dir wferp-report-runs/run-001 --sql "SELECT ..." +python3 -m skill_scripts.cli_report_harness serve-checkpoint --run-dir wferp-report-runs/run-001 --host 127.0.0.1 --port 0 +python3 -m skill_scripts.cli_report_harness wait-confirmation --run-dir wferp-report-runs/run-001 --checkpoint sql_review +python3 -m skill_scripts.cli_report_harness write-raw-table --run-dir wferp-report-runs/run-001 --rows query-result.json +python3 -m skill_scripts.cli_report_harness write-raw-preview --run-dir wferp-report-runs/run-001 +python3 -m skill_scripts.cli_report_harness run-sqlite-enrichment --run-dir wferp-report-runs/run-001 +python3 -m skill_scripts.cli_report_harness write-enriched-preview --run-dir wferp-report-runs/run-001 +python3 -m skill_scripts.cli_report_harness write-report-selection --run-dir wferp-report-runs/run-001 --report-design financial-control --include-chart --include-table --include-analysis +python3 -m skill_scripts.cli_report_harness scaffold-report --run-dir wferp-report-runs/run-001 +python3 -m skill_scripts.cli_report_harness generate-report-section --run-dir wferp-report-runs/run-001 --section-id 01-executive-summary --component-name ExecutiveSummary01Section --code-file /tmp/section.tsx +python3 -m skill_scripts.cli_report_harness validate-report-section --run-dir wferp-report-runs/run-001 --section-id 01-executive-summary +python3 -m skill_scripts.cli_report_harness write-report-draft --run-dir wferp-report-runs/run-001 --payload report-draft.json +python3 -m skill_scripts.cli_report_harness write-final-review --run-dir wferp-report-runs/run-001 --payload final-review.json +python3 -m skill_scripts.cli_report_harness can-deliver --run-dir wferp-report-runs/run-001 +``` + +`confirm` 可保存 `selectedOptions`。final review 若有 residual risks,必須用 `--accepted-residual-risk "validator_role: risk text"` 明確接受,否則 `can-deliver` 會阻擋交付。 + +複雜報表允許 LLM 依需求產生 section TSX,但必須經由 `generate-report-section` 或 `repair-report-section` 寫入單一 run-scoped section,並在每次寫入後執行 `validate-report-section`。Section 只能讀 embedded payload,不得連 DB、不得執行 SQL、不得發 network request、不得讀 credentials。 + +## 驗證 + +```bash +bash /home/timmypai/.codex/skills/wferp-report/scripts/validate-skill.sh +``` + +## Windows + +若在 Windows 看到中文亂碼,請先看 `WINDOWS_ENCODING.md`。本 skill 固定使用 UTF-8;`SKILL.md` 與 JSON 不可轉成 UTF-8 BOM、Big5、CP950、ANSI 或 UTF-16。 diff --git a/skills/wferp-report/SKILL.md b/skills/wferp-report/SKILL.md new file mode 100644 index 0000000..c847325 --- /dev/null +++ b/skills/wferp-report/SKILL.md @@ -0,0 +1,755 @@ +--- +name: wferp-report +description: Use when the user needs a WFERP/Workflow ERP management report from prompt and/or Excel inputs, including natural-language SQL generation, Excel formula/template understanding, DB-backed data preview, chart/layout confirmation, and single-file HTML report delivery. +--- + +# wferp-report + +## Production DB Baseline + +The current workstation WFERP database connection is production. Treat the +Excel/ODC connection from the user's attachments as production unless the user +explicitly provides a verified test database. + +Canonical production connection evidence: + +- ODC attachment: `C:/Users/ivychi/util/test/css04 CHD View_Customer.odc` +- Excel attachment: user-provided workbook attachment for the current run +- Provider: `SQLOLEDB.1` +- Authentication: `Integrated Security=SSPI` +- User ID in the workbook connection string: `IRO` +- Initial Catalog: `CHD` +- Data Source: `css04` +- Workstation ID in the workbook connection string: `CPC100` +- Default source object: `[CHD].[dbo].[View_Customer]` + +Production SQL rule: + +- Only execute `SELECT` statements against this database connection. +- Allowed smoke test: `SELECT TOP 1 1 AS connection_test`. +- Allowed schema probe: `SELECT TOP 0 * FROM [CHD].[dbo].[View_Customer]`. +- Do not execute `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `TRUNCATE`, `DROP`, + `ALTER`, `CREATE`, `EXEC`, stored procedures, transaction-changing commands, + or any other data-changing SQL against production. +- Local CSV, JSON, SQLite, and HTML artifacts may be written under the run + folder, but they must be labeled as local artifacts, not production writes. + +Read `references/db-config.md` before any DB-backed phase. If a requested SQL +operation is not SELECT-only, stop and ask the user for a verified +non-production environment. + +## 13-Phase Engine, 4-Step User UI + +The internal harness keeps the 13 technical phases. The Visual Companion exposes only 4 user-facing steps: + +1. Source-to-Output Logic +2. SQL Query +3. Data Result and Report Design +4. Final Delivery + +The user-facing 4 steps do not remove technical phases, validators, SQLite enrichment, lookup handling, SQL safety, repair, or delivery evidence. Each 4-step page is an aggregation over current-run artifacts and `state.json`, not a replacement for the harness. + +Step 3 must render real current-run raw and enriched data tables. The default visible preview is 50 rows, with total row count and source labels still shown. + +## Required Capability Ownership + +- Use Build Web Apps for Visual Companion UI, prompt repair controls, confirmation UX, responsive layout, and final HTML app shell when available. +- Use Build Web Data Visualization for real-data KPI, chart, table, pivot-like preview, and report visualization when available. +- Use spreadsheets for true `.xlsx` generation and workbook verification when Excel output is requested. The current implementation routes through `@oai/artifact-tool` rather than `openpyxl`. +- If a required capability is unavailable, stop and ask before falling back. + +## State-Gated Progression + +`state.json` is the workflow source of truth. The harness must not advance from chat memory, stale files, stale confirmations, or file presence alone. Confirmation identity must match the current `run_id`, `checkpoint_id`, and `payload_hash`. + +## 背景原則 + +`wferp-report` 是 **LLM-driven report generation harness**,不是 deterministic SQL 工具。主 agent / LLM 負責讀懂使用者 prompt、uploaded files、Excel 欄位與公式、WFERP schema 與 relationships、報表需求、chart/layout 偏好,並生成 SQL、資料語意、React report payload 與 single-file HTML 報告。Harness 負責 checkpoint、evidence、user confirmation、repair loop 與 gating。Subagent validators 負責獨立審查,不能用主 agent 自己的摘要取代。 + +Excel 在本 skill 中可能同時是來源資料欄位、lookup 對照表、公式邏輯、舊管理報表模板、人工加工痕跡與格式提示。預設不逐格複製 Excel;除非使用者明確要求,Excel 公式是理解報表語意的來源,而不是最終必須逐格重現的交付目標。 + +最終交付預設包含:single-file HTML 管理報告、Excel-like summary/template view、read-only SQL、data preview、SQLite enrichment evidence、validator evidence、residual risks 與可重播的 report payload。 + +--- + +## Beautiful-Article Style Harness Contract + +本 skill 參考 `beautiful-article` 的 harness 作法:**檔案是長期記憶、checkpoint 是硬停點、使用者決策不可靜默代選、reviewer / validator 不可由主 agent 自評取代**。WFERP 報表仍維持本文件的 13 個 phase;「簡化」只能簡化使用者看到的互動負擔,不能刪除 phase、evidence、validator、Visual Companion 的確認/修改機制或 SQL safety gate。 + +### 不可再犯的紅旗 + +以下任一情況都必須停止並修正,不得繼續往下跑: + +- 把 13 phase 改成 4 phase,或未經使用者明確批准合併 / 刪除 gate。 +- 用靜態 HTML、舊 run 頁面、截圖或文字摘要取代 Visual Companion 的互動 checkpoint。 +- Visual Companion 只有展示,沒有 `confirm / request changes` POST、comments、selectedOptions、checkpoint history 與 persisted confirmation。 +- 開啟的 companion URL 不是目前 run / current checkpoint,或沿用舊 confirmation。 +- 欄位很多時只顯示部分欄位,卻未提供完整欄位表、橫向捲動、欄位分類與公式 lineage。 +- 有公式、lookup、manual-only、format-only 或 unresolved 欄位,卻沒有在 HTML checkpoint 以中文說明來源、邏輯與處理方式。 +- data preview 只有 JSON 路徑或摘要,沒有表格顯示至少數筆資料、欄位名、row count、source summary。 +- validator 由主 agent 自己宣稱通過,未以 reviewer/subagent artifact 寫入 `review/validators/`。 +- validator fail / blocked 仍推進下一 phase,或一次性等到最後才驗證全部。 +- 中文 prompt / workbook path / payload 經 PowerShell command-line argument、here-string 或 pipeline 傳遞造成亂碼;大量中文內容必須透過 UTF-8 檔案或 structured in-process payload 傳遞,命令列只傳 ASCII path/flag。 +- Production DB 查詢前未完成 SQL Review checkpoint、SQL safety validator、schema mapping validator 與使用者同意。 +- 對 production DB 執行任何非 `SELECT` SQL,包含 DML、DDL、stored procedure、transaction-changing command。 +- Checkpoint、validator、gating、report design 或測試資料寫死特定 business domain、customer、database、table、view 或欄位,而不是從使用者 prompt、uploaded files、Normalized Report Plan 與 schema evidence 讀取。 + +### Phase 推進規則 + +每一個 phase 只能在自己的 gate 完成後推進。不得「先做後補」checkpoint 或 validator。 + +| 節點 | 推進條件 | 不得取代 | +|---|---|---| +| Phase 1/2 -> Phase 3 | source / Excel / schema / chart planning artifacts 已落地;required validators pass | 聊天摘要、口頭理解 | +| Phase 3 -> Phase 4 | Visual Companion current checkpoint 已顯示;使用者 POST 確認;confirmation mtime 屬於目前 checkpoint | 舊頁面、舊 confirmation、文字說「已按」但沒有檔案 | +| Phase 4 -> Phase 5 | SQL 為單一 SELECT;sql safety + schema mapping validators pass;使用者同意查詢 | smoke test、主 agent 自評 | +| Phase 5 -> Phase 6 | DB execution evidence、raw rows、columns、row count、duration、SQLite raw table 已落地;db validator pass | 空資料、mock、只存 SQL | +| Phase 6 -> Phase 7 | Raw/enriched data preview table 已顯示;aggregate/lookup/formula checks 已揭露;data validators pass;使用者確認 | JSON-only preview、只顯示欄位清單 | +| Phase 7 -> Phase 8 | 使用者選定 report type/design/chart/table/layout options;report design validators pass | 預設代選、只存 JSON、不渲染選項 | +| Phase 8/9 -> Phase 10 | report payload、sections、Excel-like view、SQL/data lineage 完整 | 手工拼 HTML、無 lineage 數字 | +| Phase 10 -> Phase 12 | report content、visual、React technical、delivery validators pass;必要時有桌面/手機視覺證據 | 主 agent 肉眼說看起來可 | +| Phase 12 -> 完成 | Final Visual Companion 顯示 HTML、evidence、validator status、residual risks;使用者接受 | 未確認即交付 | + +### Checkpoint 問題必須拆開 + +每個 checkpoint 可推薦選項,但不可把多個決策包成一個「OK」。若工具支援選項,使用獨立問題;若只能用 HTML companion,頁面上也必須用獨立區塊與欄位保存答案。 + +- Phase 3 至少拆成:欄位/來源理解、公式與 lookup 邏輯、manual/unresolved 欄位處置、初步 report type、chart/layout 初稿、residual risk。 +- Phase 4 至少拆成:是否同意此 SELECT、DB target 是否正確、filters/joins/aggregates 是否符合需求、哪些 formula/enrichment 不下推 SQL、SQL residual risk。 +- Phase 6 至少拆成:raw table preview 是否正確、enriched/SQLite preview 是否正確、aggregate/lookup/formula check 是否正確、缺值或異常是否接受。 +- Phase 7 至少拆成:report type、design style、chart set、table behavior、Excel-like view、章節順序。 +- Phase 12 至少拆成:final HTML 接受與否、warning/residual risk 接受與否、SQLite retention 選擇、是否需要再修正。 + +### Visual Companion 最低契約 + +Visual Companion 是互動工作台,不是展示檔。必須保留原本可直接回給 Codex agent 的確認與修改機制: + +- 使用目前 run 的 current checkpoint URL;不得開舊 run。 +- 頁面可 POST `confirmed` / `changes_requested`、comments、selectedOptions 與 timestamp。 +- Confirmation 必須同時比對 `run_id`、`checkpoint_id`、`payload_hash`、`confirmation_id`、`created_at`、`confirmed_at`;任一不符即視為未確認。 +- 頁面顯示中文使用者導向內容,JSON 預設隱藏到 technical details。 +- 欄位、資料與設計選項要真正渲染成 table / chart / block preview;不可只列 JSON 或欄位名稱。 +- 欄位超過畫面時使用完整表格、sticky header、橫向捲動與搜尋/篩選,不得截斷成少數欄位。 +- 公式欄位必須顯示公式語意、依賴欄位、是否下推 SQL、是否改由 SQLite/enrichment/report 層處理。 +- Data preview 至少顯示數筆資料列、欄位名、row count、source / enriched 標籤、lookup miss 與 aggregate check。 +- Report design checkpoint 必須依使用者 prompt 即時渲染候選報表區塊、圖表與表格樣貌,而不是讓使用者讀 JSON。 +- Visual Companion 功能變更後必須做 smoke test:current checkpoint URL、POST confirm、POST request changes、comments、selectedOptions、confirmation persistence 全部可用。 + +### Validator 執行規則 + +Validator 是逐 gate 的獨立 reviewer,不是最後一次總審,也不是主 agent 自評。 + +- Required validator 必須在對應 phase 完成後立即跑;pass 才能推進下一 phase。 +- Reviewer / subagent 產物必須寫入 `review/validators/*.json`,格式符合本文件的 validator JSON contract。 +- Reviewer 必須是 fresh reviewer/subagent invocation,並在 evidence 中記錄 reviewer identity、input artifact paths、timestamp 與 checked scope;主 agent 只能彙整結果,不能把自己的判斷寫成 validator pass。 +- 可合併 reviewer 只有在使用者明確批准,且輸出仍需分 gate、分 status、分 required_fixes;不得因此跳過任何 gate。 +- `warning` 可進入下一個可逆準備步驟,但 final delivery 前必須在 Visual Companion 明確讓使用者接受 residual risk。 +- `fail` / `blocked` 只能進 Phase 11 repair;repair 後只重跑受影響 validator 與必要下游 validator。 +- 內容、資料、SQL 或 HTML 在 validator 後有任何變更,受影響 validator 即失效,必須重跑。 + +### 中文與工具契約 + +- 回覆使用者、checkpoint、report UI 預設使用繁體中文。 +- 不在 PowerShell command-line argument、here-string、pipeline 中塞大型中文 prompt、workbook 摘要、JSON payload 或 HTML;改用 UTF-8 檔案輸入,並只在 shell 傳 ASCII 檔案路徑或 flags。 +- 檔案必須以 UTF-8 寫入;validator/final-review/manifest JSON 必須能 parse,且不得含 mojibake、連續問號替代中文字。 +- 每個 checkpoint/plan/source/data/validator/state artifact 寫入後,必須執行 `python -m skill_scripts.cli_report_harness check-run-text-artifacts --run-dir `;若出現 `repeated_question_marks`、`mojibake_marker` 或 UTF-8 decode failure,停止並修復,不得開 Visual Companion 或推進 gate。 +- 亂碼零容忍掃描範圍至少包含 checkpoint payload、report payload、HTML、validator JSON、manifest、SQL readable labels、檔名顯示、chart/table labels、state.json 與 final review。 +- 進 repo 後先啟用 `.tools/activate.ps1`,確保 portable `git`、`gh`、`python`、`pytest` 可用。 + +### Domain-Agnostic Gate Rule + +本 skill 是通用 WFERP 報表 harness,不可把 validator、gating、checkpoint、chart/table defaults 或 UAT scenario 寫死成特定 business domain。即使目前範例是財務、訂單、CHD、View_Customer,正式流程也必須從當次 `source/source-inventory.json`、`plan/normalized-report-plan.json`、schema evidence、SQL evidence 與使用者 prompt 取得 domain context。 + +禁止項目: + +- 在 validator 內硬編 `財務`、`訂單`、`CHD`、`View_Customer`、特定欄位或特定公司名稱作為 pass/fail 條件。 +- 在 Visual Companion 或 report design 中硬套固定業務格式,導致其他 WFERP 場景無法使用。 +- 在測試或 UAT 中只驗證單一 domain,卻宣稱 generic harness 通過。 + +允許項目: + +- 使用當次 workbook / SQL / schema evidence 中實際出現的 domain label。 +- 在範例文件中標示為 sample,但不得讓 sample 影響 gate logic。 +- 針對某個使用者任務產出 domain-specific report;但 harness 本身與 validator 必須保持 generic。 + +--- + +## 邊界(先判斷是否進入本 Skill) + +進入本 skill: + +- 使用者用 prompt 要求產生 WFERP / Workflow ERP 管理報表或 SQL。 +- 使用者提供 uploaded files,例如 Excel source workbook、明細帳、欄位清單、lookup 表、公式或既有報表模板。 +- 使用者要求費用分析、損益表、預算實績、明細帳、管理分析、異常稽核、趨勢比較等 DB-backed 報表。 +- 使用者要求以 harness 方式完成 SQL safety、DB execution、data preview、React renderer、HTML report 與 validation evidence。 + +不要進入本 skill: + +- 純靜態文章、美化長文、非 DB-backed 內容整理。 +- 純前端 dashboard prototype,不需要 WFERP schema / DB / SQL。 +- 使用者只要解釋一段 SQL,不要執行 harness。 +- 使用者要求寫入、更新或刪除 DB 資料。 + +硬邊界: + +- SQL 只允許 read-only SQL,也就是單一 `SELECT`。 +- 禁止 `INSERT`、`UPDATE`、`DELETE`、`DROP`、`ALTER`、`EXEC`、`MERGE`、`TRUNCATE`、`SELECT INTO`、multi statement、comments、`xp_`。 +- 未經 SQL checkpoint、user confirmation 與 validator gate,不得連 DB 執行查詢。 +- Browser / React renderer 只能讀 structured payload,不得連 DB、不得執行 SQL、不得保存 credentials。 +- 正式報告不得用 fake、mock、smoke 取代 DB execution evidence。 +- 不得偷偷 fallback 到 rule classifier / rule SQL generator。理解與生成預設由 LLM 完成;deterministic code 只做 guard、validation、translation、evidence persistence。 + +--- + +## 四種輸入組合(全部收斂成同一條 pipeline) + +使用者可能提供兩類資訊:資料來源需求與報表格式需求。資料來源需求可能來自 prompt 或 Excel source;報表格式需求可能來自 prompt 或 Excel report template。 + +| 資料來源需求 | 報表格式需求 | 處理方式 | +|---|---|---| +| prompt | prompt | LLM 從 prompt + WFERP schema 推 source plan,並生成 report type、chart/layout proposal。 | +| prompt | Excel template | LLM 從 prompt 推資料來源,從 Excel template 抽象報表邏輯與版型。 | +| Excel source | prompt | LLM 解析 Excel source 欄位/公式/lookup,依 prompt 動態設計報表。 | +| Excel source | Excel template | LLM 同時理解 source workbook 與 template workbook,抽象 formula/report logic。 | + +四種輸入不得分裂成四套流程。必須先 normalize 成同一份 **Normalized Report Plan**,再進 SQL / data / report harness。 + +--- + +## Normalized Report Plan + +所有輸入最後都必須轉成同一個中介規格,後續 SQL、資料預覽、報告生成只讀它: + +```json +{ + "source_requirement": {}, + "report_logic": {}, + "layout_plan": {}, + "chart_plan": {}, + "formula_semantics": {}, + "manual_exceptions": [], + "assumptions": [], + "validation_targets": [] +} +``` + +`source_requirement` 說明需要哪些 DB source tables / fields、哪些欄位由 Excel source 提供語意、哪些欄位由 lookup / SQLite enrichment 產出、哪些欄位 manual-only 或 unresolved。 + +`report_logic` 說明報表的管理問題、每個重要數字的 business definition、指標如何由 raw data / enrichment / aggregate 推得,以及 Excel 公式被保留、簡化、移除或轉成語意邏輯的原因。 + +`layout_plan` / `chart_plan` 說明 HTML 報告章節、Excel-like summary/template view、chart 類型、欄位、用途、替代方案,以及表格互動功能。 + +--- + +## 工作流總覽 + +```text +Phase 0 Intake + 判斷 prompt / uploaded files / DB / LLM / run workspace + ▼ +Phase 1 Source / Excel Requirement + LLM 解析 source prompt、Excel source、欄位、公式、lookup、template sheets + ▼ +Phase 2 Report Planning + 產出 Normalized Report Plan、report type、chart/layout proposal + ▼ +Phase 3 Field & Formula Checkpoint ★ user confirmation + HTML companion 顯示欄位、公式、manual exceptions、圖表與 layout + ▼ +Phase 4 SQL Review Checkpoint ★ user confirmation + LLM 產 SELECT;SQL safety + schema/relationship validators + ▼ +Phase 5 Confirmed DB Execution + 只在 SQL confirmed + validators pass 後查 DB,保存 raw evidence + ▼ +Phase 6 Data Preview Checkpoint ★ user confirmation + 顯示 raw/enriched data preview、aggregate checks、formula status + ▼ +Phase 7 Report Selection Checkpoint ★ user confirmation + 使用者確認 report type / design / chart / table / layout options + ▼ +Phase 8 Final Report Scaffold + React renderer scaffold + report payload + single HTML export setup + ▼ +Phase 9 Section Build + 逐段生成 report sections、chart/table/component payload + ▼ +Phase 10 Final Review + validators 檢查內容、數字、HTML、視覺、技術與 delivery evidence + ▼ +Phase 11 Repair + 最小垂直切片修復,重跑受影響 validators + ▼ +Phase 12 Delivery + 交付 single-file HTML、payload、SQL、data、validator evidence +``` + +四個主要使用者 checkpoint: + +1. **欄位/公式/規劃確認**:source plan、formula semantics、manual exceptions、chart/layout 初稿。 +2. **SQL 確認**:read-only SQL、table/field readable names、joins、filters、assumptions。 +3. **資料預覽確認**:raw preview、enriched preview、aggregate checks、lookup hit/miss。 +4. **報表選型與成品確認**:report type/design/options、final HTML、Excel-like view、residual risks。 + +--- + +## 工作區結構 + +每次 run 建立 `wferp-report-runs//`,不得只依賴聊天上下文保存決策。 + +```text +/ + source/ source-inventory.json workbook-map.json extraction-notes.md + plan/ normalized-report-plan.json report-plan.md logic-summary.md + sql/ query.sql query.sql.json sql-review-evidence.json + data/ raw-preview.json enriched-preview.json aggregate-checks.json + sqlite/ manifest.json run.sqlite3 + report/ payload/report-payload.json scaffold/ delivery/report.html + review/ validators/*.json final-review.json repair-log.md + checkpoints/ 01_field_formula.json 02_sql_review.json 03_data_preview.json 04_report_selection.json 05_final_review.json + db/ db-config.json db-env.example schema-inventory.json +``` + +檔名可以依現有 harness 實作調整,但資訊不可遺失。 + +--- + +## LLM Provider 原則 + +- 預設使用本機 Codex OAuth provider:`--llm-provider codex`。 +- 不得以 opencode 作為預設 provider。 +- 只有使用者明確指定 `--llm-provider opencode` 時才可使用 opencode。 +- 若安全政策阻擋 Codex 將 schema context 傳送到外部服務,必須停止並取得使用者明確批准;不得回退 rule fallback。 +- 使用者已在本機核准 `wferp-report` 可使用 Codex OAuth 產生 schema/context SQL 時,後續同一工作環境可直接使用,除非更高優先級政策阻擋。 + +LLM 主要負責 workbook sheet role 判斷、Excel 公式與報表邏輯理解、manual/helper/format-only 欄位抽象、schema/table/field candidate 推導、source_requirement / report_logic / chart_plan / layout_plan 生成、SQL 生成與根據 validator feedback 做最小 repair、HTML report narrative 與 Excel-like view 結構。 + +Deterministic code 主要負責 SQL guard、metadata reference validation、DB env / execution guard、SQLite temp workspace、payload schema validation、static HTML validation、evidence persistence。 + +--- + +## 硬性質檢協議 + +Subagent validators 是 gate,不是生成主內容的 helper。主 agent 不得用自己的摘要替代 validator pass。 + +| 節點 | Validator | 產物 | Gate | +|---|---|---|---| +| Phase 1/2 Understanding | `requirement_understanding_reviewer` | `review/validators/requirement-understanding.json` | Checkpoint 1 前 required | +| Phase 1/2 Excel logic | `excel_logic_reviewer` | `review/validators/excel-logic.json` | 有 Excel 時 required | +| Phase 1/2 Schema mapping | `schema_mapping_reviewer` | `review/validators/schema-mapping.json` | SQL 前 required | +| Phase 3 Chart/layout | `chart_layout_reviewer` | `review/validators/chart-layout.json` | report planning 後 required | +| Phase 4 SQL | `sql_safety_reviewer` | `review/validators/sql-safety.json` | DB execution 前 required | +| Phase 5 DB | `db_execution_reviewer` | `review/validators/db-execution.json` | Data checkpoint 前 required | +| Phase 6 Data | `data_preview_reviewer` | `review/validators/data-preview.json` | Report selection 前 required | +| Phase 6 Enrichment | `sqlite_enrichment_reviewer` | `review/validators/sqlite-enrichment.json` | 有 enrichment 時 required | +| Phase 7 Report options | `report_design_reviewer` | `review/validators/report-design.json` | Scaffold 前 required | +| Phase 8/9 Report content | `report_content_reviewer` | `review/validators/report-content.json` | Delivery 前 required | +| Phase 10 Visual | `visual_taste_reviewer` | `review/validators/visual-taste.json` | Delivery 前 required | +| Phase 10 Technical | `react_technical_reviewer` | `review/validators/react-technical.json` | Delivery 前 required | +| Phase 12 Delivery | `delivery_reviewer` | `review/validators/delivery.json` | Final checkpoint 前 required | + +每個 validator 必須輸出 JSON: + +```json +{ + "role": "sql_safety_reviewer", + "status": "pass | fail | warning | blocked", + "checked_items": [], + "evidence": [], + "findings": [], + "required_fixes": [], + "residual_risks": [] +} +``` + +鐵律:Validator `fail` 或 `blocked` 時停止該 gate,主 agent 只做最小 repair slice。Repair 後只重跑受影響 validator 與必要下游 validator。Validator `warning` 可做可逆下游準備,但 final delivery 前必須由使用者明確接受 residual risk。 + +--- + +## 決策收集鐵律 + +參考 beautiful-article 的 checkpoint 原則:不得靜默替使用者選擇。可以推薦,但不能直接替使用者決定 chart type、layout mode、report type、manual exceptions 是否忽略、residual risk 是否接受、SQL 是否可執行、DB target 是否可用於正式查詢。 + +每個 checkpoint 要在 HTML companion 顯示清楚,並由 `wait-confirmation` 等待頁面 POST。主 agent 不得只等聊天訊息或假設使用者已按。若使用者已先按過按鈕,只能在確認檔 mtime 晚於目前 checkpoint 時回收;不得沿用舊 checkpoint 的 confirmation。 + +`Excel confirmation` 是硬需求:只要有 Excel uploaded files,就必須以 HTML companion 顯示 sheet roles、欄位分類、公式語意、lookup 判斷、manual exceptions 與 unresolved items,並取得 user confirmation 後才能進 SQL。 + +--- + +## 各階段文件讀取指南(漸進載入) + +| 階段 | 必读 | 按需查 | +|---|---|---| +| Phase 0 Intake | `references/harness.md`、`references/db-config.md` | `README.md` | +| Phase 1 Source / Excel Requirement | `references/excel-intake.md`、`references/field-classification.md` | `references/schema-context.md` | +| Phase 2 Report Planning | `references/report-plan-template.md`、`references/checkpoint-payload-schema.md` | `report_designs/*.md` | +| Phase 3 Field & Formula Checkpoint | `references/checkpoint-payload-schema.md`、`references/dynamic-design-brief.md` | `references/component-policy.md` | +| Phase 4 SQL Review Checkpoint | `references/sql-safety.md`、`references/schema-context.md` | `examples/checkpoint-sql-review.json` | +| Phase 5 Confirmed DB Execution | `references/db-config.md`、`references/sql-safety.md` | `references/e2e-expense-analysis.md` | +| Phase 6 Data Preview Checkpoint | `references/sqlite-enrichment.md`、`references/validators.md` | `references/report-payload-schema.md` | +| Phase 7 Report Selection Checkpoint | `report_designs/index.json`、`report_designs/design.md`、`references/dynamic-design-brief.md` | `report_designs/*.md` | +| Phase 8 Final Report Scaffold | `references/scaffold.md`、`references/report-payload-schema.md` | `references/component-policy.md` | +| Phase 9 Section Build | `references/section-build.md`、`references/component-policy.md`、`references/rawblock-policy.md` | `references/style-replay.md` | +| Phase 10 Final Review | `references/review-checklist.md`、`references/validators.md`、`references/single-html-export.md` | `references/html-output.md` | +| Phase 11 Repair | `references/repair-policy.md` | 失敗 phase 的 reference | +| Phase 12 Delivery | `references/html-output.md`、`references/single-html-export.md` | `references/e2e-expense-analysis.md` | + +--- + +## Phase 0 —— Intake + +目標:建立 run、判斷四種輸入組合、記錄 DB/LLM 可用性。 + +輸入:使用者 prompt、uploaded files、repo root、DB config、LLM provider。 + +必讀 references:`references/harness.md`、`references/db-config.md`。 + +執行步驟:建立 `wferp-report-runs//`;產出 `source/source-inventory.json`;判斷是否有 Excel source、Excel template、lookup sheets、support sheets;判斷 report format source 是 `prompt`、`excel_template` 或 `mixed`;記錄 LLM provider,預設 `codex`;記錄 DB config 是否只存在環境變數或 run-scoped config。 + +產物:`source/source-inventory.json`、`db/db-config.json`、`checkpoints/intake-state.json`。 + +停止條件:沒有 prompt,也沒有可解析來源;使用者要求查 DB 但 DB target 不明;LLM provider 不可用且沒有使用者批准的替代方案。 + +使用者 checkpoint:通常不停止;若 DB target、uploaded files 用途或 LLM provider 不明,建立 intake checkpoint 詢問。 + +validator:無;若來源複雜可啟動 `requirement_understanding_reviewer` 做早期審查。 + +失敗時 repair slice:只修 run metadata、DB config reference 或 file inventory,不進入 downstream。 + +--- + +## Phase 1 —— Source / Excel Requirement + +目標:由 LLM 讀懂 prompt 與 Excel,不直接產 SQL。 + +輸入:`source/source-inventory.json`、使用者 prompt、uploaded files、Excel workbook extracts、WFERP schema inventory。 + +必讀 references:`references/excel-intake.md`、`references/field-classification.md`、`references/schema-context.md`。 + +執行步驟:若只有 prompt,推導 source intent、report intent、assumptions 與需確認項;若有 Excel source,判斷 sheet roles,解析欄位、sample values、公式、lookup ranges,分類 DB-backed、formula-backed、lookup-backed、manual-only、format-only、unresolved;若有 Excel template,找出報表標題、區塊、指標、公式、圖表、彙總表,建立 formula dependency graph,抽象 business metric,不逐格複製公式。 + +產物:`source/workbook-map.json`、`source/extraction-notes.md`、`plan/formula-semantics.json`、`plan/manual-exceptions.json`。 + +停止條件:Excel 無法讀取;LLM 無法判斷 sheet roles;關鍵欄位無法分類;schema/relationship context 不足以推導 candidate tables。 + +使用者 checkpoint:不單獨停止;結果要合併到 Phase 3 的 Excel confirmation / field & formula checkpoint。 + +validator:`requirement_understanding_reviewer`;有 Excel 時加 `excel_logic_reviewer`。 + +失敗時 repair slice:只修 workbook extraction、sheet role、field classification、formula semantics 或 schema context retrieval。 + +--- + +## Phase 2 —— Report Planning + +目標:把四種輸入統一成 Normalized Report Plan,並提出 report type/design/options。 + +輸入:Phase 1 產物、WFERP schema/relationship context、使用者 prompt、report_designs catalog、Excel template semantics。 + +必讀 references:`references/report-plan-template.md`、`references/checkpoint-payload-schema.md`、`references/dynamic-design-brief.md`。 + +執行步驟:產生 `plan/normalized-report-plan.json`;產生 `plan/report-plan.md`;產生 `chart_plan` 與 `layout_plan`;如果 report format source 是 prompt,必須提供可選的 report type、chart/layout proposal;如果 report format source 是 Excel template,優先抽象 template logic,模板有圖表時轉成 chart spec,沒有圖表時可建議補圖但不得強制。 + +產物:`plan/normalized-report-plan.json`、`plan/report-plan.md`、`plan/chart-plan.json`、`plan/layout-plan.json`。 + +停止條件:重要指標缺少資料 lineage;formula semantics 與 report logic 不一致;chart/layout 無法被使用者理解;manual exceptions 未揭露。 + +使用者 checkpoint:不單獨停止;Phase 3 顯示規劃結果並取得 user confirmation。 + +validator:`schema_mapping_reviewer`、`chart_layout_reviewer`;有 Excel 時加 `excel_logic_reviewer`。 + +失敗時 repair slice:只修 Normalized Report Plan、chart_plan、layout_plan 或 manual exceptions。 + +--- + +## Phase 3 —— Field & Formula Checkpoint + +目標:在 HTML companion 中讓使用者確認欄位、公式、lookup、manual exceptions、chart/layout 初稿。 + +輸入:`plan/normalized-report-plan.json`、`plan/formula-semantics.json`、`plan/chart-plan.json`、validator evidence。 + +必讀 references:`references/checkpoint-payload-schema.md`、`references/dynamic-design-brief.md`、`references/component-policy.md`。 + +執行步驟:生成 checkpoint payload;用 visual companion 呈現資料來源理解、WFERP schema/table/field readable names、relationships、Excel formula semantics、lookup 判斷、manual exceptions、report type、chart plan、layout plan;啟動 `wait-confirmation` 等待頁面 POST。 + +產物:`checkpoints/01_field_formula.json`、`review/validators/requirement-understanding.json`、`review/validators/excel-logic.json`、`review/validators/schema-mapping.json`、`review/validators/chart-layout.json`。 + +停止條件:使用者未確認;任一 required validator fail/blocked;checkpoint 頁面無法顯示;confirmation 檔案不是目前 checkpoint。 + +使用者 checkpoint:`確認規劃` 進 Phase 4;`要求修正` 回 Phase 1/2 做最小修復。 + +validator:`requirement_understanding_reviewer`、`schema_mapping_reviewer`、有 Excel 時 `excel_logic_reviewer`、`chart_layout_reviewer`。 + +失敗時 repair slice:只修被指出的欄位分類、公式語意、schema mapping、chart 或 layout。 + +--- + +## Phase 4 —— SQL Review Checkpoint + +目標:LLM 根據已確認的 Normalized Report Plan 產生 read-only SQL,並讓使用者確認後才查 DB。 + +輸入:`plan/normalized-report-plan.json`、WFERP schema/relationship context、DB target metadata、Phase 3 confirmation。 + +必讀 references:`references/sql-safety.md`、`references/schema-context.md`、`references/checkpoint-payload-schema.md`。 + +執行步驟:使用 `--llm-provider codex` 產 SQL;SQL 只查 raw DB source fields 或必要 aggregate;Excel/report formula 預設不塞進 DB,除非可由 SQL function/join 等價且有 schema evidence;執行 local SQL safety validation;執行 metadata reference validation;生成 SQL review HTML companion,顯示 SQL、table/field readable names、filters、joins、grouping、aggregates、assumptions 與不進 DB 的 formula/enrichment 欄位。 + +產物:`sql/query.sql`、`sql/query.sql.json`、`sql/sql-review-evidence.json`、`checkpoints/02_sql_review.json`。 + +停止條件:SQL safety fail;schema/relationship validator fail;使用者未按 `同意查詢`;SQL 包含非 SELECT 或 unresolved mapping。 + +使用者 checkpoint:`同意查詢` 進 Phase 5;`調整需求` 回 SQL repair 或 Phase 3。 + +validator:`sql_safety_reviewer`、`schema_mapping_reviewer`。 + +失敗時 repair slice:只修 SQL fragment、join condition、field mapping、filter 或 aggregate expression。 + +--- + +## Phase 5 —— Confirmed DB Execution + +目標:在 SQL confirmed + validators pass 後查 DB,保存可驗證 evidence。 + +輸入:`sql/query.sql`、`sql/sql-review-evidence.json`、Phase 4 user confirmation、DB config。 + +必讀 references:`references/db-config.md`、`references/sql-safety.md`、`references/e2e-expense-analysis.md`。 + +執行步驟:檢查 DB target 是 test / allowed override;執行唯讀查詢;保存 rows、columns、row count、duration、errors;建立 run-scoped SQLite workspace;將 DB raw rows 寫入 SQLite raw table。 + +產物:`data/raw-preview.json`、`data/query-execution.json`、`sqlite/manifest.json`、`sqlite/run.sqlite3`。 + +停止條件:SQL 未確認;validator 未 pass;DB_ENV 不是 test 且沒有 allow evidence;DB target 與 SQL table 不一致;SQL 執行失敗。 + +使用者 checkpoint:不單獨停止;Phase 6 顯示 data preview 並取得 user confirmation。 + +validator:`db_execution_reviewer`。 + +失敗時 repair slice:只修 DB config、SQL compatibility、connection guard 或 execution evidence,不重做 report planning。 + +--- + +## Phase 6 —— Data Preview Checkpoint + +目標:使用者確認 raw data preview、SQLite enrichment、formula/report semantic result 與 aggregate checks。 + +輸入:`data/raw-preview.json`、`sqlite/run.sqlite3`、`plan/formula-semantics.json`、`plan/normalized-report-plan.json`。 + +必讀 references:`references/sqlite-enrichment.md`、`references/validators.md`、`references/report-payload-schema.md`。 + +執行步驟:顯示 raw sample table;執行 SQLite lookup / formula / semantic enrichment;顯示 enriched summary / template semantic result;驗證 row count、aggregates、percentages、exclusions;若有 Excel template,顯示模板語意結果,不顯示逐格公式 dump。 + +產物:`data/enriched-preview.json`、`data/aggregate-checks.json`、`checkpoints/03_data_preview.json`、`review/validators/data-preview.json`。 + +停止條件:資料列數、欄位、合計或公式結果不符合 validation targets;lookup miss 未揭露;使用者未確認 data preview。 + +使用者 checkpoint:`資料正確` 進 Phase 7;`要求修正` 回 SQL / enrichment repair。 + +validator:`data_preview_reviewer`、有 enrichment 時 `sqlite_enrichment_reviewer`。 + +失敗時 repair slice:只修 enrichment rule、lookup mapping、aggregate expression、data display 或 SQL filter。 + +--- + +## Phase 7 —— Report Selection Checkpoint + +目標:讓使用者確認 report type、design、chart/table/layout options,再進 React renderer。 + +輸入:`plan/normalized-report-plan.json`、`data/enriched-preview.json`、report_designs catalog、使用者偏好。 + +必讀 references:`report_designs/index.json`、`report_designs/design.md`、`references/dynamic-design-brief.md`。 + +執行步驟:若使用者已有 prompt 報表格式,將其轉成可確認 options;若沒有,提供數個 report type/design/options,例如財務管控型、經營摘要型、明細追查型、異常稽核型、趨勢簡報型;明確列出 chart type、圖表數量、table 功能、layout 區塊、是否包含分析與建議;生成 visual companion 讓使用者看得到選項,不只列 JSON payload。 + +產物:`checkpoints/04_report_selection.json`、`plan/selected-report-design.json`、`review/validators/report-design.json`。 + +停止條件:使用者未選 report type/design/options;chart/table/layout 未確認;選項與資料量或報表目的不相容。 + +使用者 checkpoint:`確認設計` 進 Phase 8;`調整設計` 回 Phase 2/7 修正。 + +validator:`report_design_reviewer`、`chart_layout_reviewer`。 + +失敗時 repair slice:只修 report type、chart plan、table interactions、layout 或 design tokens。 + +--- + +## Phase 8 —— Final Report Scaffold + +目標:建立 React renderer scaffold 與 report payload contract,準備輸出 single-file HTML。 + +輸入:`plan/selected-report-design.json`、`data/enriched-preview.json`、`sql/query.sql`、validator evidence。 + +必讀 references:`references/scaffold.md`、`references/report-payload-schema.md`、`references/component-policy.md`。 + +執行步驟:建立 React scaffold;寫 structured `report-payload.json`;套用 selected report design;把 SQL evidence、data preview、chart plan、Excel-like view 與 validator evidence 放入 payload;確保 renderer 不連 DB、不執行 SQL、不發 network requests。 + +產物:`report/payload/report-payload.json`、`report/scaffold/`、`report/scaffold/index.html`。 + +停止條件:payload schema invalid;缺少 SQL/data/evidence lineage;scaffold 需要 network;renderer 直接讀 credentials 或 DB。 + +使用者 checkpoint:不單獨停止;Phase 10/12 會做 final review。 + +validator:`react_technical_reviewer` 可先做 scaffold-level review。 + +失敗時 repair slice:只修 payload schema、renderer scaffold 或 unsafe dependency。 + +--- + +## Phase 9 —— Section Build + +目標:逐段生成報告內容、chart/table/component payload、Excel-like view、management narrative,必要時允許 LLM 依需求產生單一 React section 程式碼。 + +輸入:`report/payload/report-payload.json`、selected design、chart/table/layout plan、data preview。 + +必讀 references:`references/section-build.md`、`references/component-policy.md`、`references/rawblock-policy.md`、`references/style-replay.md`。 + +執行步驟:生成 executive summary、KPI cards、chart sections、data tables、Excel-like summary/template view、analysis/findings、recommendations、appendix evidence;使用 semantic components 與受控 Raw layer;若固定元件不足,主 agent / LLM 可產生單一 section TSX,但必須用 `generate-report-section` 寫入、用 `validate-report-section` 驗證;修復時必須用 `repair-report-section`;所有數字必須能追到 SQL / raw data / enriched data / formula semantics。 + +產物:`report/payload/report-payload.json` 更新版、`report/sections/*.tsx`、`report/scaffold/src` 或等價 component files、`report/section-build-log.json`。 + +停止條件:section 數字無 lineage;chart/table 與 confirmed design 不一致;section code 未通過 harness safety validation;Raw layer 破壞安全或可攜性;內容遺漏 validation targets。 + +使用者 checkpoint:不單獨停止;Phase 12 final HTML 給使用者確認。 + +validator:`report_content_reviewer` 可在重要 section 完成後並行檢查;有 LLM-generated TSX 時,`react_technical_reviewer` 必須檢查 section export、data refs、safe imports、無 network/env/DB/SQL side effect、與 `Report.tsx` linkage。 + +失敗時 repair slice:只修 failing section、chart/table binding、narrative 或 evidence reference;section 程式碼修復只能替換單一 `report/sections/*.tsx`。 + +--- + +## Phase 10 —— Final Review + +目標:在交付前由 subagent validators 檢查內容正確、視覺品質、React 技術與 single-file HTML 可用性。 + +輸入:報告 HTML、report payload、SQL/data/evidence、selected design、validation targets。 + +必讀 references:`references/review-checklist.md`、`references/validators.md`、`references/single-html-export.md`、`references/html-output.md`。 + +執行步驟:執行 report content review;執行 visual/taste review,檢查版面、字級、圖表、表格、RWD、overlap、中文顯示;執行 React technical review,檢查 hydration/build、network requests = 0、不得連 DB、不得執行 SQL;執行 delivery review,檢查檔案與 evidence 完整。 + +產物:`review/validators/report-content.json`、`review/validators/visual-taste.json`、`review/validators/react-technical.json`、`review/validators/delivery.json`、`review/final-review.json`。 + +停止條件:任一 required validator fail/blocked;HTML 無法離線開啟;數字無法追溯;visual review 發現明顯排版錯誤。 + +使用者 checkpoint:不單獨停止;若 validators pass,進 Phase 12 final checkpoint;若 fail,進 Phase 11。 + +validator:`report_content_reviewer`、`visual_taste_reviewer`、`react_technical_reviewer`、`delivery_reviewer`。 + +失敗時 repair slice:只修 validator 指出的最小 component、section、style、payload 或 evidence 缺口。 + +--- + +## Phase 11 —— Repair + +目標:對 validator 或使用者指出的問題做最小垂直切片修復,不大範圍重做。 + +輸入:failed validator JSON、使用者修正要求、repair log、受影響 phase 產物。 + +必讀 references:`references/repair-policy.md` 與失敗 phase 的 reference。 + +執行步驟:分類失敗類型;只修改最小必要 artifact;重跑受影響 validator 與必要 downstream validator;更新 `review/repair-log.md`;若同一問題三次 repair 仍失敗,標記 blocked 並回報使用者決策點。 + +產物:`review/repair-log.md`、更新後 artifact、更新後 validators。 + +停止條件:修復需要改變使用者已確認的決策;同一問題三次失敗;缺少可驗證資料或 DB evidence。 + +使用者 checkpoint:若修復改變已確認的需求、SQL、資料結果或視覺設計,回到對應 checkpoint 重新取得 user confirmation。 + +validator:重跑與失敗項目相關的 validator,不重跑無關 validator。 + +失敗時 repair slice:再縮小到單一欄位、單一公式、單一 SQL fragment、單一 component 或單一 evidence file。 + +--- + +## Phase 12 —— Delivery + +目標:交付最終 single-file HTML 報告與完整 evidence packet,並讓使用者最後確認。 + +輸入:validated HTML、report payload、SQL/data/evidence、final review、SQLite retention choice。 + +必讀 references:`references/html-output.md`、`references/single-html-export.md`、`references/e2e-expense-analysis.md`。 + +執行步驟:生成 final checkpoint HTML companion;顯示 final HTML report link / preview、Excel-like view、SQL evidence、raw/enriched row counts、chart/layout summary、validator pass/fail/warning、residual risks、SQLite temp table retention choice;取得 user confirmation;保存 delivery manifest。 + +產物:`report/delivery/report.html`、`report/payload/report-payload.json`、`sql/query.sql`、`data/raw-preview.json`、`data/enriched-preview.json`、`plan/normalized-report-plan.json`、`review/final-review.json`、SQLite retention evidence。 + +停止條件:使用者未接受;delivery evidence 缺失;single-file HTML 不是離線可開;residual risks 未被接受。 + +使用者 checkpoint:`接受` / `完成` 代表 delivery;`要求修正` 回 Phase 11;`保留 SQLite` / `刪除 SQLite` 保存 retention decision。 + +validator:`delivery_reviewer` 必須 pass;有 warning 時需使用者接受 residual risks。 + +失敗時 repair slice:只補缺失 delivery file、修 final HTML、補 evidence、或回 Phase 11 修具體問題。 + +--- + +## Repair Policy 摘要 + +Repair 必須是最小垂直切片,不得大範圍重做。 + +| 失敗類型 | 最小修復範圍 | +|---|---| +| source understanding wrong | 只修 sheet role / source requirement | +| formula semantics wrong | 只修 formula/report logic | +| chart/layout wrong | 只修 chart_plan / layout_plan | +| SQL invalid | 只修 SQL fragment 或 mapping | +| DB execution failed | 只修 DB config 或 SQL compatibility | +| aggregate mismatch | 只修 enrichment / aggregate expression | +| visual issue | 只修 failing component / section | +| delivery missing evidence | 只補缺失 evidence | + +修復後只重跑相關 validator 與必要下游 validator。 + +--- + +## 預設策略 + +- 預設 repo root:`/home/timmypai/.codex/worktrees/5f5b/wferp`。 +- 預設 LLM provider:`codex`。 +- 預設輸出:single-file HTML + Excel-like summary/template view。 +- 預設報表語氣:management-first,附 technical evidence。 +- Prompt report format 時,必須提供 chart/layout proposal。 +- Excel template report format 時,優先解析 template logic;不逐格複製公式。 +- Excel 中 manual/helper/format-only 欄位預設簡化或移除,但需在 Phase 3 顯示原因。 +- SQL 預設 raw-source-first;只有可證明等價且有 schema evidence 的公式才下推 SQL。 +- DB execution 預設只允許 test DB;production 需明確 allow evidence。 +- 複雜報表可使用 LLM-generated section TSX,但只能經 `generate-report-section` / `repair-report-section` 寫入 run-scoped section,且每次都要跑 `validate-report-section`。 + +--- + +## 成功標準 + +- 四種輸入組合都能收斂成 Normalized Report Plan。 +- 使用者通過主要 checkpoint,不被迫理解所有技術細節。 +- SQL 是 confirmed read-only SQL。 +- DB execution evidence 包含 rows、columns、row count、duration、aggregates。 +- Excel formula/report logic 有 semantic lineage。 +- Chart/layout 在報告生成前已由使用者確認。 +- React renderer 產出的 HTML report 可離線開啟。 +- LLM-generated section code 已通過 section validation、React technical review 與 report content review。 +- Excel-like view 能對照報表邏輯與資料結果。 +- Required validator evidence 全部 pass;warning/residual risks 已由使用者接受。 +- 所有重要數字能追溯到 SQL raw data、SQLite enrichment 或 formula semantics。 + +--- + +## 相關資源 + +- `manifest.json` +- `README.md` +- `references/harness.md` +- `references/excel-intake.md` +- `references/field-classification.md` +- `references/schema-context.md` +- `references/sql-safety.md` +- `references/sqlite-enrichment.md` +- `references/checkpoint-payload-schema.md` +- `references/report-payload-schema.md` +- `references/validators.md` +- `references/review-checklist.md` +- `references/repair-policy.md` +- `references/html-output.md` +- `references/single-html-export.md` +- `report_designs/index.json` +- `assets/scaffold-template/` +- `scripts/validate-skill.sh` +- `scripts/scaffold-report.sh` diff --git a/skills/wferp-report/WINDOWS_ENCODING.md b/skills/wferp-report/WINDOWS_ENCODING.md new file mode 100644 index 0000000..4d4a5a0 --- /dev/null +++ b/skills/wferp-report/WINDOWS_ENCODING.md @@ -0,0 +1,19 @@ +# Windows Encoding Notes + +All files in this skill package are stored as UTF-8 without BOM unless a file +format requires otherwise. `SKILL.md` and JSON files must stay UTF-8 without BOM +because skill loaders and JSON parsers may require the first byte to be `-`, +`{`, or `[`. + +If Chinese text looks garbled on Windows, open the folder with an editor that +honors UTF-8: + +- VS Code: this folder includes `.vscode/settings.json` with `files.encoding` + set to `utf8`. +- Notepad++: choose `Encoding -> UTF-8`. +- Windows Terminal / PowerShell: use UTF-8 output, for example `chcp 65001` + before using legacy console tools. + +Do not convert these files to Big5, CP950, ANSI, or UTF-16. That will break the +Codex skill loader, JSON payload examples, and Python validation scripts. + diff --git a/skills/wferp-report/assets/sample-expense-analysis-prompt.md b/skills/wferp-report/assets/sample-expense-analysis-prompt.md new file mode 100644 index 0000000..221c917 --- /dev/null +++ b/skills/wferp-report/assets/sample-expense-analysis-prompt.md @@ -0,0 +1 @@ +請產出2026第一季費用分析,依部門與會計科目彙總未稅金額、稅額、總額與占比。請先產生 SQL 並驗證安全性,資料預覽確認後,再產出管理摘要與財務控管設計的報告。 diff --git a/skills/wferp-report/assets/scaffold-template/index.html b/skills/wferp-report/assets/scaffold-template/index.html new file mode 100644 index 0000000..a6b38ea --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/index.html @@ -0,0 +1,12 @@ + + + + + + WFERP Report + + +
    + + + diff --git a/skills/wferp-report/assets/scaffold-template/package.json b/skills/wferp-report/assets/scaffold-template/package.json new file mode 100644 index 0000000..4f772bc --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/package.json @@ -0,0 +1,18 @@ +{ + "name": "wferp-report-scaffold", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0", + "typescript": "^5.8.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": {} +} diff --git a/skills/wferp-report/assets/scaffold-template/report/Report.tsx b/skills/wferp-report/assets/scaffold-template/report/Report.tsx new file mode 100644 index 0000000..ba7b1b7 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/report/Report.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +type ReportPayload = { + title?: string; + summary?: string; + sections?: Array<{ + id: string; + title: string; + body?: string; + data_refs?: string[]; + }>; + validator_evidence?: Array<{ + validator: string; + status: string; + }>; +}; + +const payload: ReportPayload = { + title: "WFERP 報告", + summary: "請以 scaffold 隨附的 report_payload.json 取代此預設內容。", + sections: [], + validator_evidence: [] +}; + +function Report() { + return ( +
    +

    {payload.title}

    +

    {payload.summary}

    + {(payload.sections ?? []).map((section) => ( +
    +

    {section.title}

    + {section.body ?

    {section.body}

    : null} + {section.data_refs?.length ? Data refs: {section.data_refs.join(", ")} : null} +
    + ))} +
    + ); +} + +createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/skills/wferp-report/assets/scaffold-template/report/components/.gitkeep b/skills/wferp-report/assets/scaffold-template/report/components/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/report/components/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report/assets/scaffold-template/report/payload/.gitkeep b/skills/wferp-report/assets/scaffold-template/report/payload/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/report/payload/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report/assets/scaffold-template/report/raw-blocks/.gitkeep b/skills/wferp-report/assets/scaffold-template/report/raw-blocks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/report/raw-blocks/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report/assets/scaffold-template/report/sections/.gitkeep b/skills/wferp-report/assets/scaffold-template/report/sections/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/report/sections/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/wferp-report/assets/scaffold-template/tsconfig.json b/skills/wferp-report/assets/scaffold-template/tsconfig.json new file mode 100644 index 0000000..7194209 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "strict": true, + "target": "ES2022", + "types": ["vite/client"] + }, + "include": ["report/**/*.ts", "report/**/*.tsx", "report/**/*.json"] +} diff --git a/skills/wferp-report/assets/scaffold-template/vite.config.ts b/skills/wferp-report/assets/scaffold-template/vite.config.ts new file mode 100644 index 0000000..58676f7 --- /dev/null +++ b/skills/wferp-report/assets/scaffold-template/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/skills/wferp-report/examples/checkpoint-excel-confirmation.json b/skills/wferp-report/examples/checkpoint-excel-confirmation.json new file mode 100644 index 0000000..007a86f --- /dev/null +++ b/skills/wferp-report/examples/checkpoint-excel-confirmation.json @@ -0,0 +1,65 @@ +{ + "checkpointId": "excel_confirmation", + "title": "確認欄位與公式", + "actions": [ + "確認欄位與公式", + "要求修正" + ], + "payload": { + "source_type": "excel", + "db_fields": [ + { + "label": "部門代號", + "required": true, + "matched_schema_field": null + }, + { + "label": "部門名稱", + "required": true, + "matched_schema_field": null + }, + { + "label": "費用科目", + "required": true, + "matched_schema_field": null + }, + { + "label": "金額", + "required": true, + "matched_schema_field": null + } + ], + "formula_fields": [ + { + "name": "費用占比", + "formula": "=金額/總金額", + "dependencies": [ + "金額", + "總金額" + ], + "requires_user_confirmation": true + }, + { + "name": "異常旗標", + "formula": "=IF(金額>預算,\"超支\",\"正常\")", + "dependencies": [ + "金額", + "預算" + ], + "requires_user_confirmation": true + } + ], + "report_outputs": [ + { + "sheet": "管理報表", + "cells": [ + { + "address": "B6", + "formula": "=SUM(明細!D:D)" + } + ] + } + ], + "warnings": [] + } +} diff --git a/skills/wferp-report/examples/checkpoint-sql-review.json b/skills/wferp-report/examples/checkpoint-sql-review.json new file mode 100644 index 0000000..a9effa4 --- /dev/null +++ b/skills/wferp-report/examples/checkpoint-sql-review.json @@ -0,0 +1,18 @@ +{ + "checkpoint_id": "phase_4_sql_review_example", + "phase": "phase_4_sql_review", + "version": 1, + "title": "SQL 審查 checkpoint", + "summary": "此查詢讀取 2026 年費用資料,彙總部門、科目與預算差異。", + "sql": "SELECT TOP 20 MK001, MK002, MK006 FROM WFERP_EXPENSE WHERE MK002 >= '20260101' ORDER BY MK002", + "requires_user_confirmation": true, + "confirmation_prompt": "請確認是否允許執行此唯讀 SELECT 查詢。", + "validator_evidence": [ + { + "validator": "SQL 安全 validator", + "status": "pass", + "evidence_path": "evidence/sql-safety.json" + } + ], + "residual_risks": [] +} diff --git a/skills/wferp-report/manifest.json b/skills/wferp-report/manifest.json new file mode 100644 index 0000000..cf715c9 --- /dev/null +++ b/skills/wferp-report/manifest.json @@ -0,0 +1,36 @@ +{ + "name": "wferp-report", + "version": "0.1.0", + "description": "Workflow ERP schema-aware LLM-driven report harness for prompt/Excel intake, read-only SQL, DB-backed data preview, React single-file HTML reports, and subagent validator gates.", + "entrypoint": "SKILL.md", + "language": "zh-TW", + "references": [ + "references/harness.md", + "references/db-config.md", + "references/excel-intake.md", + "references/field-classification.md", + "references/schema-context.md", + "references/sql-safety.md", + "references/sqlite-enrichment.md", + "references/checkpoint-payload-schema.md", + "references/report-payload-schema.md", + "references/component-policy.md", + "references/rawblock-policy.md", + "references/react-renderer.md", + "references/scaffold.md", + "references/section-build.md", + "references/report-plan-template.md", + "references/review-checklist.md", + "references/repair-policy.md", + "references/html-output.md", + "references/validators.md", + "references/e2e-expense-analysis.md", + "references/single-html-export.md", + "references/dynamic-design-brief.md", + "references/style-replay.md" + ], + "scripts": { + "validate": "scripts/validate-skill.sh", + "scaffold_report": "scripts/scaffold-report.sh" + } +} diff --git a/skills/wferp-report/references/checkpoint-payload-schema.md b/skills/wferp-report/references/checkpoint-payload-schema.md new file mode 100644 index 0000000..3d39899 --- /dev/null +++ b/skills/wferp-report/references/checkpoint-payload-schema.md @@ -0,0 +1,31 @@ +# Checkpoint Payload Schema + +Checkpoint payload 用於在每個需要使用者確認的階段固定資料格式。 + +## 必填欄位 + +- `checkpoint_id`:唯一識別碼。 +- `phase`:例如 `phase_4_sql_review`。 +- `version`:payload 版本。 +- `title`:繁體中文標題。 +- `summary`:管理視角摘要。 +- `technical_details`:技術視角明細。 +- `requires_user_confirmation`:是否需要使用者批准。 +- `confirmation_prompt`:要求使用者確認的文字。 +- `validator_evidence`:validator 證據路徑與結果。 +- `residual_risks`:仍存在的風險。 + +## 原則 + +Checkpoint 不得包含 DB credentials。SQL checkpoint 可包含 SQL 文字,但不得包含可由瀏覽器直接執行的連線資訊。 +## Confirmation Identity Fields + +Every checkpoint payload and confirmation must carry enough identity to prevent stale approvals: + +- `run_id`: current harness run id. +- `checkpoint_id`: current checkpoint id. +- `payload_hash`: hash of the current checkpoint payload. +- `confirmation_id`: unique id generated when the user confirms or requests changes. +- `selectedOptions.changeScope`: optional repair scope such as source logic, formula logic, SQL conditions, data result, HTML design, Excel design, visual style, or delivery. + +The harness must reject a confirmation when `run_id`, `checkpoint_id`, or `payload_hash` does not match the current checkpoint. diff --git a/skills/wferp-report/references/component-policy.md b/skills/wferp-report/references/component-policy.md new file mode 100644 index 0000000..befdff0 --- /dev/null +++ b/skills/wferp-report/references/component-policy.md @@ -0,0 +1,29 @@ +# Component Policy + +wferp-report 產出的報告必須優先使用固定 React components。固定元件可被 validator 檢查資料來源、欄位 lineage、視覺結構與互動行為;不得為了排版方便任意拼貼不可驗證的 HTML。 + +## 允許元件 + +- `InsightBlock`:呈現一段管理分析、例外原因或驗證後的文字洞察。 +- `MetricGrid`:呈現有 data refs 的 KPI、差異、比率與門檻狀態。 +- `DataTable`:呈現查詢結果與 Excel-like 管理表格。必須支援排序、全文搜尋、數字/日期範圍篩選、分類多選、欄位顯示切換、凍結 key column、summary row、group subtotal row、條件格式、格式化與 CSV 匯出。 +- `ChartBlock`:呈現 bar、stacked-bar、line、area、pie、donut、combo。必須有 title、subtitle、legend、可讀 `aria-label` mark labels、empty/error state,且在資料形狀或分類數不適合時顯示 suitability warning。 +- `RecommendationList`:呈現可執行的管理建議,需連回報告中的數據或 validator evidence。 +- `EvidencePanel`:呈現 SQL safety、execution validation、formula lineage、visual validator、repair history 等驗證證據。 +- `RawBlockNotice`:只作為 RawBlock 例外的審核告示,不得執行任意程式碼。 + +## 使用規則 + +- 每個數字、圖表 mark、表格欄位與建議都必須可追溯到 `data_refs`、SQL result 欄位或 Excel formula lineage。 +- 元件內不得執行 SQL、讀取 credentials、開啟 DB 連線、呼叫外部追蹤或動態載入遠端 script。 +- 報告 section 應逐層揭露:先給管理摘要,再提供圖表/表格,最後才顯示技術 evidence。 +- `ChartBlock` 不適合高分類數、缺少數值欄位或 pie/donut 分類過多時,必須降級為 `DataTable` 或顯示 suitability warning。 +- `DataTable` 的互動只允許在本地瀏覽器處理已取得的查詢結果,不得在表格互動時重新查詢資料庫。 +- 需要客製 HTML 時,必須先加入 `RawBlockNotice`,並遵守 `rawblock-policy.md` 的例外流程。 + +## Validator 檢查點 + +- 檢查報告只使用允許元件,或 RawBlock 已有 notice 與風險紀錄。 +- 檢查 chart/table 的欄位都存在於 SQL result 或 Excel lineage。 +- 檢查互動表格仍可離線運作,CSV 匯出不包含隱藏的 credentials 或 SQL 連線資訊。 +- 檢查所有 RawBlock 都沒有 script、event handler、外部 iframe、DB/SQL side effect。 diff --git a/skills/wferp-report/references/db-config.md b/skills/wferp-report/references/db-config.md new file mode 100644 index 0000000..762e2c3 --- /dev/null +++ b/skills/wferp-report/references/db-config.md @@ -0,0 +1,54 @@ +# DB Config + +This skill currently targets the user's production WFERP database connection. +Treat this connection as production unless the user explicitly provides a +verified test database. + +## Attachment Evidence + +- ODC attachment: `C:/Users/ivychi/util/test/css04 CHD View_Customer.odc` +- Excel attachment: `C:/Users/ivychi/util/test/1 ??_????????_CHD.XLSX` +- User-provided connection string: + +```text +Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=True;User ID=IRO;Initial Catalog=CHD;Data Source=css04;Use Procedure for Prepare=1;Auto Translate=True;Packet Size=4096;Workstation ID=CPC100;Use Encryption for Data=False;Tag with column collation when possible=False +``` + +## Canonical Production Target + +- Provider: `SQLOLEDB.1` +- Authentication: `Integrated Security=SSPI` +- User ID in workbook connection string: `IRO` +- Initial Catalog: `CHD` +- Data Source: `css04` +- Workstation ID in workbook connection string: `CPC100` +- Default source object: `[CHD].[dbo].[View_Customer]` + +## SQL Safety + +Production access is read-only. + +Allowed against production: + +- `SELECT` statements only. +- Smoke test: `SELECT TOP 1 1 AS connection_test` +- Schema probe: `SELECT TOP 0 * FROM [CHD].[dbo].[View_Customer]` + +Forbidden against production: + +- `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `TRUNCATE` +- `DROP`, `ALTER`, `CREATE` +- `EXEC`, stored procedures, and procedure calls +- Transaction-changing commands or any other data-changing SQL + +Local writes are allowed only for local artifacts under the run folder, such as +CSV, JSON, SQLite, and HTML report files. These writes must not be described as +production database writes. + +## Verified Runtime Note + +A prior SELECT-only smoke test from unsandboxed Windows PowerShell as +`CHD-TECH\ivychi` using the exact OLE DB connection string returned `1`. The +same connection may fail inside the Codex sandbox with an SSL/security-provider +error; if that happens, record the failure as environment-related instead of +changing the SQL safety rule. diff --git a/skills/wferp-report/references/dynamic-design-brief.md b/skills/wferp-report/references/dynamic-design-brief.md new file mode 100644 index 0000000..6953288 --- /dev/null +++ b/skills/wferp-report/references/dynamic-design-brief.md @@ -0,0 +1,35 @@ +# Dynamic Design Brief + +Every report run creates `report-design-brief.json` before final export. The brief is the user-visible design contract for the report, not a hard-coded catalog decision. + +The agent must show both: + +- a Traditional Chinese text summary +- a semi-real HTML visual checkpoint + +The user can request more charts, different chart types, a different layout, table position changes, density changes, evidence display changes, or different management narrative emphasis. The confirmed brief is immutable input for final single HTML export. + +Required brief content: + +- report intent and target audience +- selected or generated layout recipe +- chart recipe with required columns +- table recipe and Excel-like table features +- interaction recipe, including cross-filter and evidence drawer expectations +- visual direction and density +- embedded data policy + +Checkpoint behavior: + +1. Generate brief from the validated report package and user prompt. +2. Render semi-real HTML visual checkpoint using real labels, row counts, aggregates, and expected chart/table blocks. +3. Ask user to confirm or revise. +4. If user changes prompt/query condition but wants the same style, preserve the previous style capsule and regenerate SQL/data/package before export. +5. If requested layout/chart cannot be supported by current data shape, open a design adjustment checkpoint before replacing the chart. + +Repair slice: + +- Chart mismatch: adjust only chart recipe and affected visual checkpoint. +- Layout mismatch: adjust only layout recipe and checkpoint HTML. +- Evidence presentation mismatch: adjust only evidence drawer/section recipe. +- Data mismatch: do not repair design first; return to SQL/data preview phases. diff --git a/skills/wferp-report/references/e2e-expense-analysis.md b/skills/wferp-report/references/e2e-expense-analysis.md new file mode 100644 index 0000000..9906b75 --- /dev/null +++ b/skills/wferp-report/references/e2e-expense-analysis.md @@ -0,0 +1,67 @@ +# E2E Expense Analysis + +本 skill 的費用分析 E2E 必須執行真實資料庫查詢,不可用 fake、mock 或 smoke test 取代。 + +## 執行順序 + +1. 先跑 SQLite first-pass: + +```bash +bash scripts/run_expense_analysis_sqlite_e2e.sh +``` + +預期輸出: + +```text +sqlite_expense_e2e=pass row_count=6 total_amount=120000 total_budget=100000 variance_amount=20000 max_expense_ratio=0.35 +``` + +2. 再跑 Docker PostgreSQL formal substitute: + +```bash +bash scripts/run_expense_analysis_postgres_e2e.sh +``` + +預期輸出包含 SQLite pass line,並以 PostgreSQL pass line 結束: + +```text +postgres_expense_e2e=pass row_count=6 total_amount=120000 total_budget=100000 variance_amount=20000 max_expense_ratio=0.35 +``` + +## Fixture Rows + +固定 8 筆來源資料: + +| 年度 | 部門 | 費用科目 | 金額 | 預算 | +| --- | --- | --- | ---: | ---: | +| 2026 | 行政部 | 旅費 | 35000 | 30000 | +| 2026 | 行政部 | 文具 | 10000 | 8000 | +| 2026 | 研發部 | 雲端服務 | 30000 | 25000 | +| 2026 | 研發部 | 軟體訂閱 | 25000 | 22000 | +| 2026 | 業務部 | 交際費 | 12000 | 10000 | +| 2026 | 業務部 | 廣告費 | 8000 | 5000 | +| 2025 | 行政部 | 旅費 | 9000 | 9000 | +| 2026 | 行政部 | 資產購置 | 50000 | 50000 | + +SQL 必須只納入 `year = 2026` 且 `account_type = expense` 的資料,因此包含 6 筆,排除 1 筆非 2026 資料與 1 筆非費用科目資料。 + +## 量化驗收 + +- `row_count == 6` +- `columns == ["department_code", "department_name", "expense_subject", "amount", "budget_amount", "variance_amount", "expense_ratio"]` +- `aggregates.total_amount == 120000` +- `aggregates.total_budget == 100000` +- `aggregates.variance_amount == 20000` +- `aggregates.max_expense_ratio == 0.35` +- `excluded_rows.non_2026 == 1` +- `excluded_rows.non_expense_account == 1` +- `sql_safety.readonly is True` +- `sql_safety.blocked_keywords == []` + +## 證據要求 + +交付前需提供: + +- SQLite script pass line。 +- PostgreSQL script pass line,或 Docker/sandbox 阻擋時的完整錯誤。 +- `pytest tests/skill_scripts/test_expense_analysis_sqlite_e2e.py tests/skill_scripts/test_expense_analysis_postgres_e2e.py -v` 結果。 diff --git a/skills/wferp-report/references/excel-intake.md b/skills/wferp-report/references/excel-intake.md new file mode 100644 index 0000000..62ad360 --- /dev/null +++ b/skills/wferp-report/references/excel-intake.md @@ -0,0 +1,43 @@ +# Excel Intake + +Excel workbook 必須解析 `需求欄位`、`自訂公式`、`lookup 對照表`、`管理報表`。確認 database-backed、formula-backed、lookup-backed、manual-only、unresolved references,並向使用者呈現 `資料庫欄位`、`使用者公式欄位`、`對照表欄位`、`報表輸出欄位`、`需使用者確認`。 + +## Default Workbook Rule + +- 若使用者說明 A 欄至 Z 欄由資料庫查詢,這些欄位預設分類為 `db_field`,但仍需用 WFERP schema metadata 驗證。 +- AA 欄後若為 Excel 公式、lookup 或人工欄位,預設不得送正式 DB 查詢。 +- 可由 SQL function 或 join 取得的公式欄位,仍先評估是否屬於正式 DB schema 可追溯欄位;若無法明確證明,改由 SQLite enrichment 處理。 +- lookup 對照表先匯入 run-scoped SQLite lookup table,再與 raw DB rows 產生 enriched table。 +- 無法轉譯的 Excel 公式不得忽略;必須列入 checkpoint,通知使用者並討論處理方式。 + +## Required Extraction + +對每個 sheet 記錄: + +- sheet name、used range、header row、data start row。 +- 每個欄位的 excel column、header、sample values、formula。 +- 公式 lineage:引用欄位、引用 sheet、lookup range、常數、跨 sheet reference。 +- 管理報表區塊:標題、輸出欄位、公式連結、圖表或彙總位置。 +- lookup table candidates:小型對照表、重複 key/value 區塊、VLOOKUP/XLOOKUP/INDEX-MATCH 引用範圍。 + +## Output Contract + +`excel_requirement.json` 與 `field_formula_classification.json` 需能回答: + +- 哪些欄位會進正式 DB raw SELECT。 +- 哪些欄位會由 SQLite formula / lookup enrichment 產生。 +- 哪些欄位需人工確認。 +- 每個 DB 欄位的 readable field name / description。 +- 每個公式欄位的處理狀態:`translated`、`lookup_join`、`manual_required`、`unresolved`。 +## Source-to-Output Matrix + +When Excel is provided as either source data or target report format, Step 1 must explain how source fields become report outputs. The matrix must identify: + +- source type: prompt, Excel sheet, DB field, lookup table, SQLite enrichment, formula, or manual input. +- output target: HTML block, chart, table, KPI, Excel worksheet, Excel formula, or hidden evidence. +- transformation logic: direct copy, aggregation, lookup, formula, filter, grouping, formatting, or unresolved assumption. +- formula parity: whether Excel formula output must be reproduced as values, formulas, or both. + +The goal is not to show every internal JSON field. The Visual Companion must render this as user-readable tables and labels in Traditional Chinese or the user's requested language. + +True workbook generation uses the spreadsheets skill and `@oai/artifact-tool`; do not use `openpyxl` as the default path. diff --git a/skills/wferp-report/references/field-classification.md b/skills/wferp-report/references/field-classification.md new file mode 100644 index 0000000..01c7ffd --- /dev/null +++ b/skills/wferp-report/references/field-classification.md @@ -0,0 +1,33 @@ +# Field Classification + +Excel workbook 進入 SQL 生成前,skill 必須先自動分類欄位,不要求使用者手動標註 DB 欄位或公式欄位。 + +## Classification Types + +- `db_field`:A:Z 或其他來源欄位可直接對應 WFERP schema,由正式 DB raw SELECT 取得。 +- `formula_field`:Excel 公式產生,不能送正式 DB 查詢;可轉譯時在 SQLite enrichment 補齊。 +- `lookup_field`:由 Excel 對照表、VLOOKUP/XLOOKUP/INDEX-MATCH 或可識別 lookup sheet 產生,先匯入 SQLite lookup table 再補齊。 +- `manual_only`:純人工輸入、註記或沒有可靠資料 lineage,不得猜測成 DB 查詢。 +- `unresolved`:欄位名稱、公式或 schema 對應不足,必須在 checkpoint 呈現給使用者確認。 + +## Required Metadata + +每個欄位至少記錄: + +- `excel_column`、`sheet_name`、`header`。 +- `classification`、`confidence`、`reason`。 +- `processing_location`:`db_raw_query`、`sqlite_enrichment`、`manual_review`。 +- DB 欄位需包含 `table_name`、`field_name`、`readable_name`、`description`、`relationship_path`。 +- 公式欄位需包含 `formula`、`lineage_inputs`、`translation_status`。 +- lookup 欄位需包含 `lookup_sheet`、`lookup_key_columns`、`lookup_value_columns`、`unmatched_policy`。 + +## Checkpoint Requirements + +`field_formula_classification` checkpoint 必須讓使用者同時看到管理語意與技術欄位: + +- 欄位名稱或中文/越文說明,避免只顯示無意義 field code。 +- 哪些欄位會送正式 DB 查詢。 +- 哪些欄位會留在 SQLite temp table enrichment。 +- 哪些公式或 lookup 無法自動處理,以及建議處理方式。 + +通過 `excel_classification_reviewer` 與使用者確認前,不得產出正式 SQL。 diff --git a/skills/wferp-report/references/harness.md b/skills/wferp-report/references/harness.md new file mode 100644 index 0000000..24aad58 --- /dev/null +++ b/skills/wferp-report/references/harness.md @@ -0,0 +1,183 @@ +# Harness + +執行 `wferp-report` skill 就是執行 harness flow。主 agent / LLM 負責理解、規劃與生成;harness 負責 checkpoint、user confirmation、evidence、validator gate 與 repair loop。不要只產出 SQL 後停止。 + +此 harness 採用 beautiful-article 式逐層揭露:先理解需求,再讓使用者確認欄位/公式/視覺規劃,再確認 SQL,再確認資料,再確認報表設計與最終 HTML。每個硬 gate 都必須保存 JSON payload、HTML companion、confirmation file 與 validator evidence。 + +## Input Matrix + +四種輸入組合全部收斂成同一條 pipeline: + +| 資料來源需求 | 報表格式需求 | Harness 處理 | +|---|---|---| +| prompt | prompt | LLM 從 prompt + WFERP schema 推導 source requirement、report logic、chart/layout proposal。 | +| prompt | Excel template | LLM 從 prompt 推資料來源,從 Excel template 抽象報表格式、公式與版型。 | +| Excel source | prompt | LLM 解析 Excel 欄位、公式、lookup 與 manual fields,再依 prompt 產報表規劃。 | +| Excel source | Excel template | LLM 同時理解 source workbook 與 template workbook,抽象來源資料與目標報表。 | + +後續 SQL、DB execution、SQLite enrichment、React renderer 與 single HTML delivery 只讀 `plan/normalized-report-plan.json` 與確認後的資料/evidence。 + +## Phase Map + +| Phase | 名稱 | 主要輸出 | 硬 gate | +|---|---|---|---| +| 0 | Intake | `source/source-inventory.json`、`db/db-config.json` | 不明確時停止 | +| 1 | Source / Excel Requirement | `source/workbook-map.json`、`plan/formula-semantics.json` | 併入 Phase 3 | +| 2 | Report Planning | `plan/normalized-report-plan.json`、`plan/chart-plan.json`、`plan/layout-plan.json` | 併入 Phase 3 | +| 3 | Field & Formula Checkpoint | `checkpoints/01_field_formula.json` | 必須 user confirmation | +| 4 | SQL Review Checkpoint | `sql/query.sql`、`checkpoints/02_sql_review.json` | 必須 user confirmation | +| 5 | Confirmed DB Execution | `data/raw-preview.json`、`sqlite/run.sqlite3` | SQL confirmed + validators pass | +| 6 | Data Preview Checkpoint | `data/enriched-preview.json`、`checkpoints/03_data_preview.json` | 必須 user confirmation | +| 7 | Report Selection Checkpoint | `plan/selected-report-design.json`、`checkpoints/04_report_selection.json` | 必須 user confirmation | +| 8 | Final Report Scaffold | `report/payload/report-payload.json`、`report/scaffold/` | payload/schema gate | +| 9 | Section Build | LLM-generated `report/sections/*.tsx`、chart/table/component payload | section validator gate | +| 10 | Final Review | `review/final-review.json`、validators | validators pass / accepted warning | +| 11 | Repair | `review/repair-log.md` | 重跑受影響 validators | +| 12 | Delivery | `report/delivery/report.html`、delivery manifest | final user confirmation | + +## Main Checkpoints + +使用者主要只面對四個 checkpoint。內部 phase 可以更多,但不要讓使用者在每個小步驟被打斷。 + +1. **Field & Formula Checkpoint**:確認 uploaded files、Excel confirmation、sheet roles、DB-backed / formula-backed / lookup-backed / manual-only 欄位、WFERP schema/relationship mapping、report logic、chart/layout 初稿。 +2. **SQL Review Checkpoint**:確認 read-only SQL、table/field readable names、joins、filters、aggregates、assumptions 與不下推 DB 的公式/enrichment 欄位。 +3. **Data Preview Checkpoint**:確認 DB raw data preview、SQLite enrichment、lookup hit/miss、formula status、aggregate checks 與 excluded rows。 +4. **Report Selection / Final Checkpoint**:確認 report type、design/options、chart/table/layout、React HTML report、Excel-like view、validator evidence、residual risks 與 SQLite retention。 + +## CLI Flow + +所有命令都輸出 JSON。被 gate 擋下時,命令必須以 non-zero exit code 結束,並在 stderr 輸出 JSON error。 + +```bash +python3 -m skill_scripts.cli_report_harness create-run \ + --run-root wferp-report-runs \ + --run-id run-001 \ + --prompt "查詢費用分析" \ + --llm-provider codex + +python3 -m skill_scripts.cli_report_harness classify-workbook \ + --run-dir wferp-report-runs/run-001 \ + --input-file requirement.xlsx \ + --llm-provider codex + +python3 -m skill_scripts.cli_report_harness write-sql-review \ + --run-dir wferp-report-runs/run-001 \ + --sql "SELECT department, amount FROM expenses" + +python3 -m skill_scripts.cli_report_harness check-run-text-artifacts \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness serve-checkpoint \ + --run-dir wferp-report-runs/run-001 \ + --host 127.0.0.1 \ + --port 0 + +python3 -m skill_scripts.cli_report_harness wait-confirmation \ + --run-dir wferp-report-runs/run-001 \ + --checkpoint sql_review \ + --timeout-seconds 1800 + +python3 -m skill_scripts.cli_report_harness init-sqlite-workspace \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness write-raw-table \ + --run-dir wferp-report-runs/run-001 \ + --rows query-result.json + +python3 -m skill_scripts.cli_report_harness write-raw-preview \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness run-sqlite-enrichment \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness write-enriched-preview \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness write-report-selection \ + --run-dir wferp-report-runs/run-001 \ + --report-design financial-control \ + --include-chart \ + --include-table \ + --include-analysis + +python3 -m skill_scripts.cli_report_harness scaffold-report \ + --run-dir wferp-report-runs/run-001 + +python3 -m skill_scripts.cli_report_harness generate-report-section \ + --run-dir wferp-report-runs/run-001 \ + --section-id 01-executive-summary \ + --component-name ExecutiveSummary01Section \ + --code-file /tmp/section.tsx + +python3 -m skill_scripts.cli_report_harness validate-report-section \ + --run-dir wferp-report-runs/run-001 \ + --section-id 01-executive-summary + +python3 -m skill_scripts.cli_report_harness write-report-draft \ + --run-dir wferp-report-runs/run-001 \ + --payload report-draft.json + +python3 -m skill_scripts.cli_report_harness write-final-review \ + --run-dir wferp-report-runs/run-001 \ + --payload final-review.json + +python3 -m skill_scripts.cli_report_harness can-deliver \ + --run-dir wferp-report-runs/run-001 +``` + +## Companion / Confirmation Contract + +`serve-checkpoint` 會啟動本地確認服務並輸出 checkpoint URL JSON。瀏覽器只讀 checkpoint payload,不得連 DB、不得執行 SQL、不得保存 credentials。 + +主 agent 不得只等待使用者在聊天端回報按了哪個按鈕。每次開啟 checkpoint companion 後,都要用 `wait-confirmation` 阻塞等待瀏覽器 POST 產生的 confirmation file: + +```bash +python3 -m skill_scripts.cli_report_harness wait-confirmation \ + --run-dir wferp-report-runs/run-001 \ + --checkpoint field_formula \ + --timeout-seconds 1800 +``` + +`wait-confirmation` 成功時會輸出 `status=confirmed`、`action`、`comment`、`selectedOptions` 與 `confirmation_file`。若 agent 啟動等待前使用者已在瀏覽器按過按鈕,只有在 confirmation file 屬於目前 checkpoint 且 mtime 晚於 checkpoint payload 時,才可使用 `--allow-existing` 回收。 + +## Gate Rules + +- 每次寫入 checkpoint、plan、source、data、validator 或 `state.json` 後,必須先執行 `check-run-text-artifacts`。若出現 `repeated_question_marks`、`mojibake_marker` 或 UTF-8 decode failure,不得開 Visual Companion、不得等待使用者確認、不得推進 gate。 +- 中文 prompt、workbook 摘要、JSON payload、HTML 或 report content 不得透過 PowerShell command-line argument、here-string 或 pipeline 傳給 Python/Node;必須寫入 UTF-8 檔案,CLI 只傳 ASCII path/flag。 +- `write-raw-table` 必須在 SQL Review Checkpoint action 為 `同意查詢`,且 `sql_safety_reviewer`、`schema_mapping_reviewer` 都 pass 後才能執行。 +- `write-raw-preview` 必須在 raw table 寫入後執行,讓使用者先確認 DB 資料本身。 +- `run-sqlite-enrichment` 必須在 Field & Formula Checkpoint 已確認、lookup import 完成、raw preview 可追溯時執行。 +- `write-enriched-preview` 必須呈現 enrichment 後欄位、formula status、lookup hit/miss、row count 與 aggregate checks。 +- `write-report-selection` 之前必須有 `db_execution_reviewer`、`data_preview_reviewer` 與必要的 `sqlite_enrichment_reviewer` evidence。 +- `scaffold-report` 必須在 Report Selection Checkpoint 已確認後執行。 +- `generate-report-section` / `repair-report-section` 只允許寫入單一 run-scoped `report/sections/*.tsx`,不得寫其他 repo 或系統檔案。 +- `validate-report-section` 必須在每個 LLM-generated section 寫入或修復後執行,並確認 section export、data refs、safe imports、無 network/env/DB/SQL side effect、與 `Report.tsx` linkage。 +- `write-report-draft` 必須使用確認後的 report design、chart plan、layout plan 與 data package。 +- `write-final-review` 必須在 draft 可開啟後執行,且 `report_content_reviewer`、`visual_taste_reviewer`、`react_technical_reviewer`、`delivery_reviewer` 都已輸出 evidence。 +- `can-deliver` 只有在 required validators 都 pass,或 final checkpoint 明確接受相符 residual risks 時才允許 delivery。 +- SQLite temp table retention 必須在 delivery 前詢問使用者是否保留;若選擇刪除,需保存 cleanup evidence。 + +## Subagent Gate Flow + +- 主 agent 負責產生 checkpoint、spawn reviewer、彙整 reviewer JSON、驗證 contract、判斷 gate。 +- subagent reviewer 負責內容驗證,包含 contract、evidence、資料、SQL、schema、報告內容、HTML 視覺與互動。 +- validator gate 是 subagent review 的結果,不是主 agent 另行自填。 +- 任一 reviewer `fail` 或 `blocked`:停止該硬 gate,主 agent 依 `requiredFixes` 做最小 repair slice,修復後只重跑該 reviewer 與受影響下游 reviewer。 +- 任一 reviewer `warning`:可進入可逆下游工作,但 final delivery 前必須在 final checkpoint 由使用者接受 role-prefixed residual risk。 +- 可並行工作可先做,但不能越過依賴 gate。例如 SQL reviewer 執行中可準備 report plan 摘要,但不可執行 DB query。 +## 13-Phase Engine, 4-Step User Workbench + +The harness keeps all 13 technical phases. The Visual Companion groups them into 4 user-facing steps: + +| User Step | Technical Coverage | Required Evidence | +|---|---|---| +| 1. Source-to-Output Logic | Phase 0-3 | source inventory, Excel extraction, source-to-output matrix, formula semantics, schema mapping, chart/layout plan | +| 2. SQL Query | Phase 4 | single SELECT SQL, readable table/field mapping, SQL safety validator, schema validator, user query consent | +| 3. Data Result and Report Design | Phase 5-7 | production SELECT evidence, raw data preview, local SQLite table, enriched preview, 50-row table preview, chart/table/report options | +| 4. Final Delivery | Phase 8-12 | report payload, single-file HTML, true `.xlsx`, validator evidence, visual evidence, residual risk decision | + +`state.json` is the source of truth for all transitions. A checkpoint can advance only when the current `run_id`, `checkpoint_id`, and `payload_hash` match the persisted confirmation. Stale files, stale browser tabs, and chat memory do not unlock a gate. + +The companion can be simplified to 4 user steps, but the harness must not remove validator execution, SQLite enrichment, prompt repair, production SELECT-only safety, or final delivery evidence. + +Capability ownership remains explicit: use Build Web Apps for the companion and final HTML UI, Build Web Data Visualization for real-data KPI/chart/table/report visualization, and spreadsheets for true `.xlsx` workbook generation and verification. diff --git a/skills/wferp-report/references/html-output.md b/skills/wferp-report/references/html-output.md new file mode 100644 index 0000000..d683783 --- /dev/null +++ b/skills/wferp-report/references/html-output.md @@ -0,0 +1,11 @@ +# HTML Output + +Final report HTML 必須可離線開啟並保留 evidence。 + +## 要求 + +- 不依賴遠端 DB。 +- 不包含 credentials。 +- 圖表與表格的資料來自 report payload。 +- 每個主要指標需可追溯到 SQL execution result。 +- Delivery packet 需列出 HTML 路徑、payload 路徑、SQL 路徑與 validator evidence。 diff --git a/skills/wferp-report/references/rawblock-policy.md b/skills/wferp-report/references/rawblock-policy.md new file mode 100644 index 0000000..4c77c6b --- /dev/null +++ b/skills/wferp-report/references/rawblock-policy.md @@ -0,0 +1,36 @@ +# RawBlock Policy + +RawBlock 是例外機制,不是預設排版方式。wferp-report 預設只能使用固定 React components;只有固定元件無法忠實呈現已驗證內容時,才允許使用 RawBlock。 + +## 可使用情境 + +- 固定元件無法表達的特殊管理表格、稽核聲明、法遵揭露或 ERP 欄位對照說明。 +- 已有 validator evidence 證明內容、欄位、公式與查詢結果一致。 +- 使用者明確確認 RawBlock 的目的、資料依賴與風險等級。 + +## 必填紀錄 + +每個 RawBlock 都必須先呈現 `RawBlockNotice`,並包含: + +- `id`:穩定且唯一的 RawBlock 識別碼。 +- `title`:使用者可理解的標題。 +- `purpose`:為何需要 RawBlock。 +- `dataDependencies`:SQL result 欄位、ERP schema 欄位或 Excel formula lineage。 +- `riskLevel`:`low`、`medium`、`high`。 +- 替代元件不可行原因。 +- 視覺 validator、資料 validator、SQL safety validator 結果。 + +## 禁止事項 + +RawBlock 不得包含: + +- ` + """, + encoding="utf-8", + ) + + result = _run_cli(["validate-single-html", "--html", str(html)], cwd=Path.cwd()) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "validated" + assert payload["valid"] is True + assert payload["errors"] == [] + + +def test_cli_validate_single_html_exits_nonzero_for_network_reference(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ + + """, + encoding="utf-8", + ) + + result = _run_cli(["validate-single-html", "--html", str(html)], cwd=Path.cwd()) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["status"] == "validated" + assert "external_script" in payload["errors"] + assert "missing_package" in payload["errors"] + + +def test_cli_validate_single_html_reports_missing_file_on_stderr(tmp_path: Path): + missing = tmp_path / "missing-report.html" + + result = _run_cli(["validate-single-html", "--html", str(missing)], cwd=Path.cwd()) + + assert result.returncode == 2 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload["status"] == "error" + assert payload["code"] == "single_html_validation_error" + assert str(missing) in payload["message"] + + +def test_cli_inspect_style_replay_allows_compatible_columns(): + result = _run_cli( + [ + "inspect-style-replay", + "--capsule", + json.dumps(_style_capsule(), ensure_ascii=False), + "--columns", + "period,amount", + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "checked" + assert payload["requires_checkpoint"] is False + + +def test_cli_inspect_style_replay_requires_checkpoint_for_missing_columns(): + result = _run_cli( + [ + "inspect-style-replay", + "--capsule", + json.dumps(_style_capsule(), ensure_ascii=False), + "--columns", + "department,amount", + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["status"] == "checked" + assert payload["requires_checkpoint"] is True + assert payload["incompatible_charts"] == ["period_trend"] + + +def test_cli_report_harness_accepts_excel_input_path(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + run_dir = tmp_path / "runs" / "with-excel" + + result = _run_cli( + ["--prompt", "請產出費用分析", "--input-file", str(workbook), "--run-dir", str(run_dir)], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert str(workbook) in state["input_files"] + assert state["excel_requirement"]["database_fields"] + + +def test_cli_report_harness_builds_excel_checkpoint(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + run_dir = tmp_path / "runs" / "excel-checkpoint" + + result = _run_cli( + [ + "--prompt", + "請產出費用分析", + "--input-file", + str(workbook), + "--run-dir", + str(run_dir), + "--checkpoint", + "excel", + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + checkpoint = json.loads( + (run_dir / "checkpoints" / "01_excel_confirmation.json").read_text(encoding="utf-8") + ) + assert checkpoint["title"] == "確認欄位與公式" + + +def test_cli_report_harness_builds_sql_checkpoint(tmp_path: Path): + run_dir = tmp_path / "runs" / "sql-checkpoint" + llm_payload = { + "sql": "SELECT TOP 50 [ML006], [ML008] FROM [VPIC1].[dbo].[ACTML]", + "used_tables": ["ACTML"], + "assumptions": ["依 prompt 判斷為費用明細查詢,先取費用科目與金額欄位。"], + "confidence": 0.95, + } + + result = _run_cli( + [ + "--prompt", + "我要看這個月的費用資料", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql", + "--llm-provider", + "mock", + "--llm-model", + "mock", + ], + cwd=Path.cwd(), + env={"LLM_MOCK_RESPONSE": json.dumps(llm_payload, ensure_ascii=False)}, + ) + + assert result.returncode == 0, result.stderr + checkpoint = json.loads((run_dir / "checkpoints" / "02_sql_review.json").read_text(encoding="utf-8")) + assert checkpoint["title"] == "SQL 查詢確認" + assert checkpoint["payload"]["sql"].lstrip().upper().startswith("SELECT") + + +def test_cli_report_harness_does_not_execute_db_without_confirmation_flag(tmp_path: Path): + run_dir = tmp_path / "runs" / "no-execution" + llm_payload = { + "sql": "SELECT TOP 50 [ML006], [ML008] FROM [VPIC1].[dbo].[ACTML]", + "used_tables": ["ACTML"], + "assumptions": ["依 prompt 判斷為費用明細查詢,先取費用科目與金額欄位。"], + "confidence": 0.95, + } + + result = _run_cli( + [ + "--prompt", + "我要看這個月的費用資料", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql", + "--validate-execution", + "--llm-provider", + "mock", + "--llm-model", + "mock", + ], + cwd=Path.cwd(), + env={"DB_ENV": "test", "LLM_MOCK_RESPONSE": json.dumps(llm_payload, ensure_ascii=False)}, + ) + + assert result.returncode == 0, result.stderr + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["execution_result_summary"] is None + assert "SQL 尚未確認" in result.stdout + + +def test_cli_report_harness_requires_allow_non_test_db_execution_for_non_test_env(tmp_path: Path): + run_dir = tmp_path / "runs" / "prod-block" + + result = _run_cli( + [ + "--prompt", + "查詢採購單前 20 筆", + "--run-dir", + str(run_dir), + "--confirm-sql", + "--validate-execution", + ], + cwd=Path.cwd(), + env={"DB_ENV": "production"}, + ) + + assert result.returncode != 0 + assert "--allow-non-test-db-execution" in result.stderr + + +def test_cli_confirm_sql_requires_existing_sql_review(tmp_path: Path): + run_dir = tmp_path / "runs" / "confirm-without-sql" + + result = _run_cli( + [ + "--prompt", + "請產出費用分析", + "--run-dir", + str(run_dir), + "--confirm-sql", + "--validate-execution", + ], + cwd=Path.cwd(), + env={"DB_ENV": "test"}, + ) + + assert result.returncode != 0 + assert "reviewed SQL" in result.stderr + + +def test_cli_report_selection_persists_report_type_and_design(tmp_path: Path): + run_dir = tmp_path / "runs" / "report-selection" + + result = _run_cli( + [ + "--prompt", + "請產出費用分析", + "--run-dir", + str(run_dir), + "--report-type", + "管理摘要", + "--report-design", + "financial-control", + "--include-chart", + "--include-table", + "--include-analysis", + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["report_type"] == "管理摘要" + assert state["report_design"] == "financial-control" + assert state["report_options"]["include_chart"] is True + + +def test_cli_report_selection_rejects_unknown_report_design(tmp_path: Path): + run_dir = tmp_path / "runs" / "unknown-report-design" + + result = _run_cli( + [ + "--prompt", + "請產出費用分析", + "--run-dir", + str(run_dir), + "--checkpoint", + "report-selection", + "--report-design", + "rogue-design", + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 2 + error = json.loads(result.stderr) + assert error == { + "status": "error", + "code": "unknown_report_design", + "message": "Unknown report design profile: rogue-design", + } + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["report_design"] is None + assert not (run_dir / "checkpoints" / "04_report_selection.json").exists() + + +def test_cli_full_flow_blocks_and_advances_by_confirmation(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-001" + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-001", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + + assert created.returncode == 0, created.stderr + assert json.loads(created.stdout)["status"] == "created" + + sql_review = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--sql", + "SELECT department, amount FROM expenses", + ], + cwd=Path.cwd(), + ) + + assert sql_review.returncode == 0, sql_review.stderr + assert json.loads(sql_review.stdout)["checkpoint"] == "sql_review" + ReportHarness(run_dir).update_state(validator_results=_sql_gate_validator_results()) + + blocked_preview = _run_cli( + [ + "write-data-preview", + "--run-dir", + str(run_dir), + "--payload", + '{"rows":[]}', + ], + cwd=Path.cwd(), + ) + + assert blocked_preview.returncode == 2 + assert "SQL must be confirmed" in blocked_preview.stderr + assert not (run_dir / "data" / "execution-result.json").exists() + + confirmed = _run_cli( + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--action", + "同意查詢", + ], + cwd=Path.cwd(), + ) + + assert confirmed.returncode == 0, confirmed.stderr + confirmed_payload = json.loads(confirmed.stdout) + assert confirmed_payload["status"] == "confirmed" + assert confirmed_payload["confirmation"]["checkpoint_id"] == "sql_review" + assert len(confirmed_payload["confirmation"]["payload_hash"]) == 64 + + data_preview = _run_cli( + [ + "write-data-preview", + "--run-dir", + str(run_dir), + "--payload", + '{"rows":[{"department":"管理部","amount":1000}],"columns":["department","amount"],"row_count":1}', + ], + cwd=Path.cwd(), + ) + + assert data_preview.returncode == 0, data_preview.stderr + assert json.loads(data_preview.stdout)["checkpoint"] == "data_preview" + + +def test_cli_write_sql_review_prompt_only_requires_llm_provider(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-prompt-only-no-provider" + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-prompt-only-no-provider", + "--prompt", + "我要看這個月的費用資料", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + result = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + ], + cwd=Path.cwd(), + env={"LLM_PROVIDER": "none"}, + ) + + assert result.returncode == 2 + error = json.loads(result.stderr) + assert error["code"] == "sql_review_error" + assert error["message"] == "LLM_PROVIDER_NOT_CONFIGURED" + assert not (run_dir / "sql" / "query.sql").exists() + + +def test_cli_write_sql_review_prompt_only_uses_llm_first_without_rule_fallback( + tmp_path: Path, +): + run_root = tmp_path / "runs" + run_dir = run_root / "run-prompt-only-llm" + llm_payload = { + "sql": "SELECT TOP 50 [ML006], [ML008] FROM [VPIC1].[dbo].[ACTML]", + "used_tables": ["ACTML"], + "assumptions": ["依 prompt 判斷為費用明細查詢,先取費用科目與金額欄位。"], + "confidence": 0.95, + } + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-prompt-only-llm", + "--prompt", + "我要看這個月的費用資料", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + result = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--llm-provider", + "mock", + "--llm-model", + "mock", + ], + cwd=Path.cwd(), + env={"LLM_MOCK_RESPONSE": json.dumps(llm_payload, ensure_ascii=False)}, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["checkpoint"] == "sql_review" + query_payload = json.loads((run_dir / "sql" / "query.sql.json").read_text(encoding="utf-8")) + assert query_payload["sql"] == llm_payload["sql"] + assert query_payload["validation"]["route"] == "llm" + assert query_payload["validation"]["reason"] == "OK" + assert query_payload["validation"]["llm_provider"] == "mock" + assert (run_dir / "sql" / "query.sql").read_text(encoding="utf-8").strip() == llm_payload["sql"] + + +def test_cli_wait_confirmation_returns_existing_checkpoint_confirmation(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-wait-existing" + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-wait-existing", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + sql_review = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--sql", + "SELECT department, amount FROM expenses", + ], + cwd=Path.cwd(), + ) + assert sql_review.returncode == 0, sql_review.stderr + + confirmed = _run_cli( + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--action", + "調整需求", + "--comment", + "請加入期預算比較", + "--selected-option", + "chart=bar", + ], + cwd=Path.cwd(), + ) + assert confirmed.returncode == 0, confirmed.stderr + + waited = _run_cli( + [ + "wait-confirmation", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--allow-existing", + "--timeout-seconds", + "0.1", + "--poll-interval-seconds", + "0.01", + ], + cwd=Path.cwd(), + ) + + assert waited.returncode == 0, waited.stderr + payload = json.loads(waited.stdout) + assert payload["status"] == "confirmed" + assert payload["checkpoint"] == "sql_review" + assert payload["action"] == "調整需求" + assert payload["comment"] == "請加入期預算比較" + assert payload["selectedOptions"] == {"chart": "bar"} + + +def test_cli_wait_confirmation_accepts_existing_confirmation_by_default(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-wait-existing-default" + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-wait-existing-default", + "--prompt", + "查詢訂單資料", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + sql_review = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--sql", + "SELECT department, amount FROM expenses", + ], + cwd=Path.cwd(), + ) + assert sql_review.returncode == 0, sql_review.stderr + + confirmed = _run_cli( + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--action", + "同意查詢", + ], + cwd=Path.cwd(), + ) + assert confirmed.returncode == 0, confirmed.stderr + + waited = _run_cli( + [ + "wait-confirmation", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--timeout-seconds", + "0.1", + "--poll-interval-seconds", + "0.01", + ], + cwd=Path.cwd(), + ) + + assert waited.returncode == 0, waited.stderr + payload = json.loads(waited.stdout) + assert payload["status"] == "confirmed" + assert payload["action"] == "同意查詢" + + +def test_cli_wait_confirmation_times_out_when_user_has_not_confirmed(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-wait-timeout" + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-wait-timeout", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + sql_review = _run_cli( + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--sql", + "SELECT department, amount FROM expenses", + ], + cwd=Path.cwd(), + ) + assert sql_review.returncode == 0, sql_review.stderr + + waited = _run_cli( + [ + "wait-confirmation", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--timeout-seconds", + "0.05", + "--poll-interval-seconds", + "0.01", + ], + cwd=Path.cwd(), + ) + + assert waited.returncode == 2 + error = json.loads(waited.stderr) + assert error["code"] == "confirmation_timeout" + assert error["checkpoint"] == "sql_review" + + +def test_cli_full_flow_writes_draft_final_review_and_delivery_gate(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-002" + final_review_payload = tmp_path / "final-review.json" + final_review_payload.write_text( + json.dumps(_passing_final_review_payload(), ensure_ascii=False), + encoding="utf-8", + ) + visual_brief = json.dumps( + { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "primary_goal": "Expose financial-control exceptions.", + }, + "catalog_guardrail": "financial-control", + "target_audience": {"role": "finance controller", "needs": ["expense review"]}, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": ["executive-summary", "kpi-overview", "data-table"], + "density": "analysis-first", + }, + "chart_recipe": [ + { + "id": "expense-trend", + "type": "line", + "purpose": "Reveal period trend and month-over-month movement.", + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": ["filter", "sort", "drilldown", "column_visibility"], + "row_count": 1, + } + ], + "interaction_recipe": { + "filters": ["department", "amount"], + "drilldowns": ["department"], + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": ["variance", "outliers"], + }, + "embedded_data_policy": {"mode": "smart-tiered", "summary_threshold_rows": 5000}, + }, + ensure_ascii=False, + ) + visual_package = json.dumps( + { + "catalog_guardrail": "financial-control", + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "data_profile": {"columns": ["department", "amount"], "row_count": 1}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [{"department": "管理部", "amount": 1000}], + }, + "aggregates": {"amount_sum": 1000, "amount_avg": 1000}, + }, + ensure_ascii=False, + ) + + steps = [ + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-002", + "--prompt", + "查詢費用分析", + ], + [ + "write-sql-review", + "--run-dir", + str(run_dir), + "--sql", + "SELECT department, amount FROM expenses", + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "sql_review", + "--action", + "同意查詢", + ], + [ + "write-data-preview", + "--run-dir", + str(run_dir), + "--payload", + '{"rows":[{"department":"管理部","amount":1000}],"columns":["department","amount"],"row_count":1}', + ], + [ + "write-report-selection", + "--run-dir", + str(run_dir), + "--report-type", + "管理摘要", + "--report-design", + "financial-control", + "--include-chart", + "--include-table", + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "report_selection", + "--action", + "產生報告", + ], + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + '{"catalog_guardrail":"financial-control","prompt":"查詢費用分析","data_profile":{"columns":["department","amount"],"row_count":1},"datasets":{"columns":["department","amount"]}}', + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "design_brief", + "--action", + "確認設計", + ], + [ + "write-visual-checkpoint", + "--run-dir", + str(run_dir), + "--brief", + visual_brief, + "--package", + visual_package, + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "visual_design", + "--action", + "確認視覺設計", + ], + [ + "write-report-draft", + "--run-dir", + str(run_dir), + "--payload", + '{"sections":["executive-summary","data-table"],"workspace":"report"}', + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "report_draft", + "--action", + "接受", + ], + [ + "write-final-review", + "--run-dir", + str(run_dir), + "--payload", + str(final_review_payload), + ], + ] + + for step in steps: + result = _run_cli(step, cwd=Path.cwd()) + assert result.returncode == 0, result.stderr + if step[0] == "write-sql-review": + ReportHarness(run_dir).update_state(validator_results=_sql_gate_validator_results()) + + delivery = _run_cli(["can-deliver", "--run-dir", str(run_dir)], cwd=Path.cwd()) + + assert delivery.returncode == 0, delivery.stderr + assert json.loads(delivery.stdout)["allowed"] is True + assert (run_dir / "checkpoints" / "04a_design_brief.json").exists() + assert (run_dir / "checkpoints" / "04b_visual_design.json").exists() + assert (run_dir / "visual" / "visual-checkpoint.html").exists() + assert (run_dir / "checkpoints" / "05_report_draft.json").exists() + assert (run_dir / "checkpoints" / "06_final_review.json").exists() + + +def test_cli_write_design_brief_success(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-design" + package = tmp_path / "package.json" + package.write_text( + json.dumps( + { + "catalog_guardrail": "financial-control", + "prompt": "查詢費用分析", + "data_profile": { + "columns": ["month", "department", "amount"], + "row_count": 1, + }, + "datasets": {"columns": ["month", "department", "amount"]}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-design", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + result = _run_cli( + ["write-design-brief", "--run-dir", str(run_dir), "--package", str(package)], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + checkpoint = json.loads(result.stdout) + assert checkpoint["checkpoint"] == "design_brief" + assert checkpoint["payload"]["embedded_data_policy"]["mode"] == "smart-tiered" + assert checkpoint["payload"]["chart_recipe"][0]["type"] == "line" + + +def test_cli_write_visual_checkpoint_success(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-visual" + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-visual", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + brief = { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "primary_goal": "Expose financial-control exceptions.", + }, + "catalog_guardrail": "financial-control", + "target_audience": {"role": "finance controller", "needs": ["expense review"]}, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": ["executive-summary", "kpi-overview", "data-table"], + "density": "analysis-first", + }, + "chart_recipe": [ + { + "id": "expense-trend", + "type": "line", + "purpose": "Reveal period trend and month-over-month movement.", + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": ["filter", "sort", "drilldown", "column_visibility"], + "row_count": 1, + } + ], + "interaction_recipe": {"filters": ["department", "amount"], "drilldowns": ["department"]}, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": ["variance", "outliers"], + }, + "embedded_data_policy": {"mode": "smart-tiered", "summary_threshold_rows": 5000}, + } + package = { + "catalog_guardrail": "financial-control", + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "data_profile": {"columns": ["department", "amount"], "row_count": 1}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [{"department": "管理部", "amount": 1000}], + }, + "aggregates": {"amount_sum": 1000, "amount_avg": 1000}, + } + harness_steps = [ + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + json.dumps(package, ensure_ascii=False), + ], + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "design_brief", + "--action", + "確認設計", + ], + ] + for step in harness_steps: + result = _run_cli(step, cwd=Path.cwd()) + assert result.returncode == 0, result.stderr + + result = _run_cli( + [ + "write-visual-checkpoint", + "--run-dir", + str(run_dir), + "--brief", + json.dumps(brief, ensure_ascii=False), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 0, result.stderr + checkpoint = json.loads(result.stdout) + html_text = (run_dir / "visual" / "visual-checkpoint.html").read_text(encoding="utf-8") + assert checkpoint["checkpoint"] == "visual_design" + assert checkpoint["payload"]["kpis"][0] == {"label": "amount_sum", "value": 1000} + assert "費用分析視覺設計確認" in html_text or "visual-checkpoint" in html_text + assert not html_text.lstrip().startswith("{") + assert '"html":' not in html_text + assert "fetch(" not in html_text + + +def test_cli_rewriting_design_brief_removes_stale_visual_checkpoint_html(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-visual-invalidation" + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-visual-invalidation", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + package = { + "catalog_guardrail": "financial-control", + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "data_profile": {"columns": ["department", "amount"], "row_count": 1}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [{"department": "管理部", "amount": 1000}], + }, + "aggregates": {"amount_sum": 1000, "amount_avg": 1000}, + } + design_brief = _run_cli( + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + assert design_brief.returncode == 0, design_brief.stderr + brief_payload = json.loads(design_brief.stdout)["payload"] + + confirmed = _run_cli( + [ + "confirm", + "--run-dir", + str(run_dir), + "--checkpoint", + "design_brief", + "--action", + "確認設計", + ], + cwd=Path.cwd(), + ) + assert confirmed.returncode == 0, confirmed.stderr + + visual = _run_cli( + [ + "write-visual-checkpoint", + "--run-dir", + str(run_dir), + "--brief", + json.dumps(brief_payload, ensure_ascii=False), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + assert visual.returncode == 0, visual.stderr + assert (run_dir / "checkpoints" / "04b_visual_design.json").exists() + assert (run_dir / "visual" / "visual-checkpoint.html").exists() + + rewritten = _run_cli( + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + json.dumps({**package, "prompt": "重寫費用分析設計"}, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + assert rewritten.returncode == 0, rewritten.stderr + rewritten_brief_payload = json.loads(rewritten.stdout)["payload"] + assert not (run_dir / "checkpoints" / "04b_visual_design.json").exists() + assert not (run_dir / "visual" / "visual-checkpoint.html").exists() + + blocked_visual = _run_cli( + [ + "write-visual-checkpoint", + "--run-dir", + str(run_dir), + "--brief", + json.dumps(rewritten_brief_payload, ensure_ascii=False), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + assert blocked_visual.returncode == 2 + error = json.loads(blocked_visual.stderr) + assert error["code"] == "visual_checkpoint_error" + assert "Design brief must be confirmed" in error["message"] + assert not (run_dir / "visual" / "visual-checkpoint.html").exists() + + +def test_cli_write_visual_checkpoint_does_not_leave_html_when_design_brief_unconfirmed( + tmp_path: Path, +): + run_root = tmp_path / "runs" + run_dir = run_root / "run-visual-unconfirmed" + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-visual-unconfirmed", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + package = { + "catalog_guardrail": "financial-control", + "prompt": "查詢費用分析", + "report_type": "管理摘要", + "data_profile": {"columns": ["department", "amount"], "row_count": 1}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [{"department": "管理部", "amount": 1000}], + }, + "aggregates": {"amount_sum": 1000, "amount_avg": 1000}, + } + design_brief = _run_cli( + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + assert design_brief.returncode == 0, design_brief.stderr + + result = _run_cli( + [ + "write-visual-checkpoint", + "--run-dir", + str(run_dir), + "--brief", + json.dumps(json.loads(design_brief.stdout)["payload"], ensure_ascii=False), + "--package", + json.dumps(package, ensure_ascii=False), + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 2 + error = json.loads(result.stderr) + assert error["code"] == "visual_checkpoint_error" + assert "Design brief must be confirmed" in error["message"] + assert not (run_dir / "visual" / "visual-checkpoint.html").exists() + + +def test_cli_write_design_brief_reports_validation_errors_from_overrides(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-invalid-design" + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-invalid-design", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + result = _run_cli( + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + '{"catalog_guardrail":"financial-control","data_profile":{"columns":["department","amount"]},"datasets":{"columns":["department","amount"]}}', + "--overrides", + '{"chart_recipe":[{"type":"combo"}]}', + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 2 + error = json.loads(result.stderr) + assert error["code"] == "design_brief_invalid" + assert "chart_recipe[0].purpose" in error["message"] + + +def test_cli_write_design_brief_rejects_malformed_package_catalog_guardrail(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-invalid-catalog" + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-invalid-catalog", + "--prompt", + "查詢費用分析", + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + result = _run_cli( + [ + "write-design-brief", + "--run-dir", + str(run_dir), + "--package", + '{"catalog_guardrail":["bad"],"data_profile":{"columns":["department","amount"]},"datasets":{"columns":["department","amount"]}}', + ], + cwd=Path.cwd(), + ) + + assert result.returncode == 2 + error = json.loads(result.stderr) + assert error["code"] == "design_brief_invalid" + assert "catalog_guardrail" in error["message"] + + +def test_cli_sqlite_enrichment_flow_writes_raw_and_enriched_checkpoints(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-sqlite" + workbook = tmp_path / "req.xlsx" + _classification_workbook(workbook) + classification_llm_payload = { + "candidate_tables": [ + { + "table_id": "ACTML", + "table_name": "分類帳檔", + "reason": "費用明細帳交易資料最可能存在於會計分類帳檔。", + "confidence": 0.9, + } + ], + "column_mappings": [ + { + "excel_column": "A", + "excel_header": "科目編號", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML006", + "fields": [{"table_id": "ACTML", "column_id": "ML006", "business_meaning": "明細科目編號"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.9, + "reason": "LLM 先選 ACTML,再映射科目欄位。", + "risks": [], + }, + { + "excel_column": "B", + "excel_header": "傳票日期", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML002", + "fields": [{"table_id": "ACTML", "column_id": "ML002", "business_meaning": "傳票日期"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.86, + "reason": "LLM 先選 ACTML,再映射日期欄位。", + "risks": [], + }, + { + "excel_column": "C", + "excel_header": "金額-原幣", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML014", + "fields": [{"table_id": "ACTML", "column_id": "ML014", "business_meaning": "原幣金額"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.86, + "reason": "LLM 先選 ACTML,再映射金額欄位。", + "risks": [], + }, + { + "excel_column": "D", + "excel_header": "BU", + "classification": "excel_enrichment_field", + "processing_location": "sqlite_enrichment", + "source_expression": "=VLOOKUP(A2,對照表!A:B,2,0)", + "fields": [], + "relationship_path": [], + "lookup_sheet": "對照表", + "confidence": 0.9, + "reason": "BU 由 Excel lookup 對照表取得,應於 SQLite enrichment 補齊。", + "risks": [], + }, + ], + "assumptions": ["公式欄位不送正式 DB,先查 raw DB 欄位後於 SQLite enrichment 補齊。"], + } + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-sqlite", + "--prompt", + "費用分析", + "--input-file", + str(workbook), + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + classified = _run_cli( + [ + "classify-workbook", + "--run-dir", + str(run_dir), + "--input-file", + str(workbook), + "--primary-sheet", + "明細帳", + "--llm-provider", + "mock", + "--llm-model", + "mock", + ], + cwd=Path.cwd(), + env={"LLM_MOCK_RESPONSE": json.dumps(classification_llm_payload, ensure_ascii=False)}, + ) + assert classified.returncode == 0, classified.stderr + assert json.loads(classified.stdout)["checkpoint"] == "field_formula_classification" + + initialized = _run_cli(["init-sqlite-workspace", "--run-dir", str(run_dir)], cwd=Path.cwd()) + assert initialized.returncode == 0, initialized.stderr + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert Path(state["sqlite_manifest_path"]).exists() + + imported = _run_cli( + [ + "import-lookups", + "--run-dir", + str(run_dir), + "--input-file", + str(workbook), + "--sheet-name", + "對照表", + "--logical-name", + "lookup_account_category", + "--key-column", + "A", + "--value-column", + "bu=B", + "--value-column", + "expense_category=C", + ], + cwd=Path.cwd(), + ) + assert imported.returncode == 0, imported.stderr + imported_payload = json.loads(imported.stdout) + assert imported_payload["imported_row_count"] == 1 + assert imported_payload["ignored_row_count"] == 5 + lookup_table = imported_payload["table_name"] + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["sqlite_manifest"]["lookup_row_counts"] == {lookup_table: 1} + assert state["sqlite_manifest"]["ignored_lookup_rows"][lookup_table][0]["reason"] == "header_or_metadata" + + raw_rows = tmp_path / "raw-rows.json" + raw_rows.write_text('[{"account_code":"6111","amount":100}]', encoding="utf-8") + raw = _run_cli(["write-raw-table", "--run-dir", str(run_dir), "--rows", str(raw_rows)], cwd=Path.cwd()) + assert raw.returncode == 0, raw.stderr + + raw_preview = _run_cli(["write-raw-preview", "--run-dir", str(run_dir)], cwd=Path.cwd()) + assert raw_preview.returncode == 0, raw_preview.stderr + raw_payload = json.loads(raw_preview.stdout) + assert raw_payload["checkpoint"] == "raw_data_preview" + assert raw_payload["payload"]["row_count"] == 1 + assert raw_payload["payload"]["columns"] == ["account_code", "amount"] + + enriched = _run_cli( + [ + "run-sqlite-enrichment", + "--run-dir", + str(run_dir), + "--computed-columns", + '[{"name":"amount_twice","expression":"raw.\\"amount\\" * 2"}]', + "--lookup-columns", + ( + '[{"name":"expense_category","lookup_table":"' + + lookup_table + + '","raw_key":"account_code","lookup_key":"account_code","lookup_value":"expense_category"}]' + ), + ], + cwd=Path.cwd(), + ) + assert enriched.returncode == 0, enriched.stderr + + enriched_preview = _run_cli(["write-enriched-preview", "--run-dir", str(run_dir)], cwd=Path.cwd()) + assert enriched_preview.returncode == 0, enriched_preview.stderr + enriched_payload = json.loads(enriched_preview.stdout) + assert enriched_payload["checkpoint"] == "enriched_data_preview" + assert enriched_payload["payload"]["sample_rows"] == [ + { + "account_code": "6111", + "amount": 100, + "expense_category": "8.租金支出", + "amount_twice": 200, + } + ] + + retention = _run_cli(["write-sqlite-retention", "--run-dir", str(run_dir)], cwd=Path.cwd()) + assert retention.returncode == 0, retention.stderr + retention_payload = json.loads(retention.stdout) + assert retention_payload["checkpoint"] == "sqlite_retention" + table_names = {item["table_name"] for item in retention_payload["payload"]["tables"]} + assert lookup_table in table_names + + +def test_cli_classify_workbook_accepts_current_session_payload(tmp_path: Path): + run_root = tmp_path / "runs" + run_dir = run_root / "run-current-session" + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + classification_payload = { + "workbook_path": str(workbook), + "primary_sheet": "Requirement", + "resolver_strategy": "current_session_codex_table_first_resolver", + "llm_status": "used_current_session", + "llm_provider": "current-session", + "candidate_tables": [ + { + "table_id": "COPTC", + "table_name": "Order header", + "reason": "Current Codex session selected order header table.", + "confidence": 0.82, + } + ], + "columns": [ + { + "excel_column": "A", + "excel_header": "Order No", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "COPTC.TC001", + "lookup_sheet": "", + "field_metadata": [], + "lineage_inputs": [], + "relationship_path": [], + "schema_candidates": [], + "confidence": "high", + "reason": "Provided by authenticated current Codex session.", + "risks": [], + } + ], + "lookup_sheet_inventory": [], + } + payload_path = tmp_path / "classification.json" + payload_path.write_text(json.dumps(classification_payload, ensure_ascii=False), encoding="utf-8") + + created = _run_cli( + [ + "create-run", + "--run-root", + str(run_root), + "--run-id", + "run-current-session", + "--prompt", + "訂單分析", + "--input-file", + str(workbook), + ], + cwd=Path.cwd(), + ) + assert created.returncode == 0, created.stderr + + classified = _run_cli( + [ + "classify-workbook", + "--run-dir", + str(run_dir), + "--input-file", + str(workbook), + "--classification-payload", + str(payload_path), + "--llm-provider", + "none", + ], + cwd=Path.cwd(), + ) + + assert classified.returncode == 0, classified.stderr + assert json.loads(classified.stdout)["checkpoint"] == "field_formula_classification" + written = json.loads((run_dir / "data" / "column-classification.json").read_text(encoding="utf-8")) + assert written["llm_provider"] == "current-session" + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["column_classification"]["resolver_strategy"] == "current_session_codex_table_first_resolver" + + +def test_harness_sql_review_clears_sqlite_preview_checkpoints(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-state", prompt="費用分析") + harness.write_sql_review("SELECT old", {"status": "pending_user_confirmation"}) + harness.write_raw_data_preview({"row_count": 1, "columns": ["account_code"], "sample_rows": []}) + harness.write_enriched_data_preview({"row_count": 1, "columns": ["account_code"], "sample_rows": []}) + harness.write_sqlite_retention({"manifest_path": "/tmp/manifest.json", "tables": []}) + + harness.write_sql_review("SELECT new", {"status": "pending_user_confirmation"}) + state = harness.state() + checkpoints = {item["checkpoint"] for item in state["checkpoints"]} + + assert "raw_data_preview" not in checkpoints + assert "enriched_data_preview" not in checkpoints + assert "sqlite_retention" not in checkpoints + assert state["raw_data_preview"] is None + assert state["enriched_data_preview"] is None + assert state["sqlite_retention"] is None + + +def test_harness_raw_preview_clears_enriched_and_report_downstream(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-state", prompt="費用分析") + harness.write_raw_data_preview({"row_count": 1, "columns": ["account_code"], "sample_rows": []}) + harness.write_enriched_data_preview({"row_count": 1, "columns": ["account_code"], "sample_rows": []}) + harness.write_sqlite_retention({"manifest_path": "/tmp/manifest.json", "tables": []}) + harness.write_report_selection({"selected_report_type": "expense-analysis", "selected_options": {}}) + harness.confirm("report_selection", "產生報告") + + harness.write_raw_data_preview({"row_count": 2, "columns": ["account_code"], "sample_rows": []}) + state = harness.state() + checkpoints = {item["checkpoint"] for item in state["checkpoints"]} + + assert "enriched_data_preview" not in checkpoints + assert "sqlite_retention" not in checkpoints + assert "report_selection" not in checkpoints + assert "report_selection" not in state.get("user_confirmations", {}) + assert state["enriched_data_preview"] is None + assert state["sqlite_retention"] is None + assert state["report_options"] == {} diff --git a/tests/skill_scripts/test_dashboard_builder_e2e.py b/tests/skill_scripts/test_dashboard_builder_e2e.py new file mode 100644 index 0000000..e62321d --- /dev/null +++ b/tests/skill_scripts/test_dashboard_builder_e2e.py @@ -0,0 +1,224 @@ +import json +from typing import Protocol, cast + +from pytest import MonkeyPatch + +import skill_scripts.governed_query as governed_query_module +from skill_scripts.dashboard_canvas import DashboardCanvas, create_dashboard_chart_draft +from skill_scripts.dashboard_publish import ( + DashboardDraft, + PublishedDashboard, + approve_dashboard_draft, + create_published_dashboard_link, + preview_dashboard_draft, + publish_dashboard, + refresh_published_dashboard_view, + render_published_dashboard_link, + revoke_published_dashboard_link, +) +from skill_scripts.design_template_catalog import select_design_template +from skill_scripts.execution_validator import ExecutionExpectation +from skill_scripts.governed_query import GovernedQueryResult, approve_query_result, capture_live_llm_query_evidence +from skill_scripts.sql_router import RoutingOptions + + +def _bundle() -> dict[str, list[dict[str, str]]]: + return { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + {"DB": "VPIC1.dbo.", "TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "TableNameViet": "", "ModuleID": "ACT"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán"}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ"}, + ], + "index_keys": [ + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + +class _FakeDbConfig: + def __init__(self, env: str = "test") -> None: + self.env: str = env + + +class _FakeDbClient: + def __init__(self) -> None: + self.config: _FakeDbConfig = _FakeDbConfig(env="test") + self.calls: list[str] = [] + + def execute_readonly(self, sql: str) -> list[dict[str, object]]: + self.calls.append(sql) + return [{"MK002": "2026", "MK006": 1000.0}] + + def health_check(self) -> tuple[bool, str]: + return True, "OK" + + +class _FakePublishedDashboardDataSource: + def __init__(self) -> None: + self.calls: list[str] = [] + + def read_published_dashboard(self, published: PublishedDashboard, *, reason: str) -> dict[str, object]: + _ = published + self.calls.append(reason) + return {"rows": [{"MK002": "2026", "MK006": 1000.0}], "reason": reason} + + +ChartPayload = dict[str, object] + + +class _RunGovernedQuery(Protocol): + def __call__( + self, + prompt: str, + bundle: dict[str, list[dict[str, str]]], + options: RoutingOptions, + db_client: _FakeDbClient, + sample_rows_limit: int = 5, + ) -> GovernedQueryResult: ... + + +def test_cli_target_case_covers_prompt_to_published_dashboard_journey(monkeypatch: MonkeyPatch) -> None: + run_governed_query = cast(_RunGovernedQuery, governed_query_module.run_governed_query) + monkeypatch.setenv( + "LLM_MOCK_RESPONSE", + json.dumps( + { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + ) + query_result: GovernedQueryResult = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(), + ) + + chart_payload: ChartPayload = { + "chart_type": "bar", + "title": "2026 Budget", + "x_field": "MK002", + "y_field": "MK006", + "number_format": {"field": "MK006", "style": "currency", "currency": "TWD"}, + } + try: + _ = create_dashboard_chart_draft(query_result, chart_payload) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "QUERY_RESULT_NOT_APPROVED" in str(exc) + + approved_query = approve_query_result(query_result) + chart_draft = create_dashboard_chart_draft(approved_query, chart_payload) + canvas = DashboardCanvas(columns=12, rows=8) + _ = canvas.add_chart("budget", chart_draft, x=0, y=0, w=6, h=4) + _ = canvas.move_chart("budget", x=3, y=1) + _ = canvas.resize_chart("budget", w=7, h=4) + + template = select_design_template("soft_operations") + draft = DashboardDraft( + dashboard_id="dashboard-ac17", + canvas_payload=canvas.to_payload(), + template_selection=template, + ) + preview = preview_dashboard_draft(draft) + assert preview["template"] == template.to_payload() + assert preview["canvas"] == { + "columns": 12, + "rows": 8, + "items": [ + { + "chart_id": "budget", + "x": 3, + "y": 1, + "w": 7, + "h": 4, + "chart": { + "chart_type": "bar", + "title": "2026 Budget", + "x_field": "MK002", + "y_field": "MK006", + "number_format": {"field": "MK006", "style": "currency", "currency": "TWD"}, + }, + } + ], + } + + try: + _ = publish_dashboard(draft) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_FINAL_APPROVAL_REQUIRED" in str(exc) + + published = publish_dashboard(approve_dashboard_draft(draft, approver="analyst-1")) + link = create_published_dashboard_link(published) + data_source = _FakePublishedDashboardDataSource() + + rendered = render_published_dashboard_link( + token=link.token, + links=[link], + dashboards=[published], + data_source=data_source, + now="2026-05-07T00:00:00+00:00", + ) + assert rendered["status"] == "ok" + assert rendered["view_only"] is True + assert rendered["authoring_actions"] == [] + + refreshed = refresh_published_dashboard_view( + rendered, + published=published, + data_source=data_source, + refresh_interval_seconds=300, + ) + assert refreshed["refresh"] == {"status": "fresh", "interval_seconds": 300} + assert data_source.calls == ["view", "refresh"] + + revoked = revoke_published_dashboard_link(link) + denied = render_published_dashboard_link( + token=revoked.token, + links=[revoked], + dashboards=[published], + data_source=data_source, + now="2026-05-07T00:00:00+00:00", + ) + assert denied["status"] == "denied" + assert denied["code"] == "LINK_TOKEN_REVOKED" + assert denied["dashboard"] is None + assert denied["data"] is None + + +def test_live_llm_governed_query_evidence_is_non_blocking_when_provider_unavailable() -> None: + evidence = capture_live_llm_query_evidence( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="none", + llm_model="none", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(), + ) + + assert evidence == { + "prompt": "查詢2026年的工程預算明細", + "status": "failed", + "failure_classification": "llm_availability", + "blocking": False, + "evidence_type": "live_llm_governed_query", + "error_code": "LLM_PROVIDER_NOT_CONFIGURED", + } diff --git a/tests/skill_scripts/test_dashboard_canvas.py b/tests/skill_scripts/test_dashboard_canvas.py new file mode 100644 index 0000000..b33db1e --- /dev/null +++ b/tests/skill_scripts/test_dashboard_canvas.py @@ -0,0 +1,240 @@ +from skill_scripts.chart_spec import author_chart_spec +from skill_scripts.dashboard_canvas import DashboardCanvas, DashboardChartDraft, create_dashboard_chart_draft, load_dashboard_canvas +from skill_scripts.governed_query import GovernedQueryResult, QueryEvidence, approve_query_result +from typing import cast + + +def _query_result() -> GovernedQueryResult: + return GovernedQueryResult( + evidence=QueryEvidence( + prompt="查詢2026年月營收", + sql="SELECT [month],[revenue] FROM [dbo].[SALES]", + route="rule", + route_reason="test", + validation_status="OK", + execution_status="OK", + execution_timestamp="2026-05-07T00:00:00+00:00", + returned_columns=["month", "revenue"], + row_count=2, + sample_rows=[ + {"month": "2026-01", "revenue": 1000.0}, + {"month": "2026-02", "revenue": 1500.0}, + ], + ) + ) + + +def _bar_chart(title: str = "Monthly Revenue"): + return author_chart_spec( + approve_query_result(_query_result()), + { + "chart_type": "bar", + "title": title, + "x_field": "month", + "y_field": "revenue", + }, + ) + + +def _bar_chart_draft(title: str = "Monthly Revenue"): + return create_dashboard_chart_draft( + approve_query_result(_query_result()), + { + "chart_type": "bar", + "title": title, + "x_field": "month", + "y_field": "revenue", + }, + ) + + +def test_approved_chart_draft_can_be_added_to_dashboard_canvas() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + + placed = canvas.add_chart("revenue", _bar_chart_draft(), x=0, y=0, w=6, h=4) + + assert placed.chart_id == "revenue" + assert canvas.render_layout() == [ + { + "chart_id": "revenue", + "x": 0, + "y": 0, + "w": 6, + "h": 4, + "chart": { + "chart_type": "bar", + "title": "Monthly Revenue", + "x_field": "month", + "y_field": "revenue", + }, + } + ] + + +def test_canvas_rejects_invalid_chart_drafts_before_placement() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + + cases: list[object] = [object(), _bar_chart(), DashboardChartDraft(chart=_bar_chart(), preview={})] + for invalid_draft in cases: + try: + _ = canvas.add_chart("bad", invalid_draft, x=0, y=0, w=6, h=4) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "INVALID_CHART_DRAFT" in str(exc) + + try: + _ = create_dashboard_chart_draft( + _query_result(), + { + "chart_type": "bar", + "title": "Monthly Revenue", + "x_field": "month", + "y_field": "revenue", + }, + ) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "QUERY_RESULT_NOT_APPROVED" in str(exc) + + +def test_chart_can_be_moved_and_resized_without_losing_identity() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + _ = canvas.add_chart("revenue", _bar_chart_draft(), x=0, y=0, w=4, h=3) + + moved = canvas.move_chart("revenue", x=4, y=1) + resized = canvas.resize_chart("revenue", w=5, h=4) + + assert moved.chart_id == "revenue" + assert resized.chart_id == "revenue" + assert canvas.render_layout()[0]["chart_id"] == "revenue" + assert canvas.render_layout()[0]["x"] == 4 + assert canvas.render_layout()[0]["y"] == 1 + assert canvas.render_layout()[0]["w"] == 5 + assert canvas.render_layout()[0]["h"] == 4 + + +def test_layout_persistence_round_trips_coordinates_dimensions_and_chart_payloads() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + _ = canvas.add_chart("revenue", _bar_chart_draft("Revenue"), x=0, y=0, w=6, h=4) + _ = canvas.add_chart("trend", _bar_chart_draft("Trend"), x=6, y=0, w=6, h=4) + + payload = canvas.to_payload() + restored = load_dashboard_canvas(payload) + + assert payload == { + "columns": 12, + "rows": 8, + "items": [ + { + "chart_id": "revenue", + "x": 0, + "y": 0, + "w": 6, + "h": 4, + "chart": {"chart_type": "bar", "title": "Revenue", "x_field": "month", "y_field": "revenue"}, + }, + { + "chart_id": "trend", + "x": 6, + "y": 0, + "w": 6, + "h": 4, + "chart": {"chart_type": "bar", "title": "Trend", "x_field": "month", "y_field": "revenue"}, + }, + ], + } + assert restored.render_layout() == canvas.render_layout() + + +def test_overlap_and_bounds_violations_are_rejected_deterministically() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + _ = canvas.add_chart("revenue", _bar_chart_draft("Revenue"), x=0, y=0, w=6, h=4) + + cases = [ + lambda: canvas.add_chart("overlap", _bar_chart_draft("Overlap"), x=5, y=0, w=4, h=4), + lambda: canvas.add_chart("bounds", _bar_chart_draft("Bounds"), x=10, y=0, w=4, h=4), + lambda: canvas.move_chart("revenue", x=-1, y=0), + lambda: canvas.resize_chart("revenue", w=13, h=4), + ] + expected_codes = [ + "DASHBOARD_CANVAS_OVERLAP:overlap:revenue", + "DASHBOARD_CANVAS_OUT_OF_BOUNDS:bounds", + "DASHBOARD_CANVAS_OUT_OF_BOUNDS:revenue", + "DASHBOARD_CANVAS_OUT_OF_BOUNDS:revenue", + ] + + for attempt, expected_code in zip(cases, expected_codes, strict=True): + try: + _ = attempt() + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert expected_code in str(exc) + + +def test_persisted_layout_replay_rejects_invalid_overlap_and_bounds_payloads() -> None: + payload = { + "columns": 12, + "rows": 8, + "items": [ + {"chart_id": "one", "x": 0, "y": 0, "w": 6, "h": 4, "chart": _bar_chart("One").to_payload()}, + {"chart_id": "two", "x": 5, "y": 0, "w": 6, "h": 4, "chart": _bar_chart("Two").to_payload()}, + ], + } + + try: + _ = load_dashboard_canvas(payload) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_CANVAS_OVERLAP:two:one" in str(exc) + + +def test_canvas_rejects_fractional_grid_values() -> None: + canvas = DashboardCanvas(columns=12, rows=8) + + cases = [ + lambda: DashboardCanvas(columns=cast(int, 12.5), rows=8), + lambda: canvas.add_chart("fractional", _bar_chart_draft(), x=cast(int, 0.5), y=0, w=6, h=4), + lambda: canvas.add_chart("string-fractional", _bar_chart_draft(), x=cast(int, cast(object, "0.5")), y=0, w=6, h=4), + lambda: load_dashboard_canvas( + { + "columns": 12, + "rows": 8, + "items": [{"chart_id": "one", "x": 0.5, "y": 0, "w": 6, "h": 4, "chart": _bar_chart("One").to_payload()}], + } + ), + ] + + for attempt in cases: + try: + _ = attempt() + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_CANVAS_INVALID_PAYLOAD" in str(exc) + + +def test_persisted_layout_replay_rejects_malformed_chart_metadata() -> None: + cases = [ + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "sort": {"field": "month", "direction": 1}}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "sort": {"field": "month", "direction": "sideways"}}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "number_format": {"field": "revenue", "unknown": "x"}}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "number_format": {"field": "revenue", "style": "bogus"}}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "date_format": "YYYY-MM"}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "date_format": {"field": "month", "format": "MM/DD"}}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "color": "blue"}, + {"chart_type": "bar ", "title": "Bad", "x_field": "month", "y_field": "revenue"}, + {"chart_type": "bar", "title": "Bad", "x_field": " month", "y_field": "revenue"}, + {"chart_type": "bar", "title": "Bad", "x_field": "month", "y_field": "revenue", "color": " #ff0000"}, + ] + + for chart_payload in cases: + try: + _ = load_dashboard_canvas( + { + "columns": 12, + "rows": 8, + "items": [{"chart_id": "one", "x": 0, "y": 0, "w": 6, "h": 4, "chart": chart_payload}], + } + ) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "INVALID_CHART_DRAFT" in str(exc) diff --git a/tests/skill_scripts/test_dashboard_publish.py b/tests/skill_scripts/test_dashboard_publish.py new file mode 100644 index 0000000..de08b55 --- /dev/null +++ b/tests/skill_scripts/test_dashboard_publish.py @@ -0,0 +1,371 @@ +from skill_scripts.dashboard_canvas import DashboardCanvas, create_dashboard_chart_draft, load_dashboard_canvas +from typing import cast + +from skill_scripts.dashboard_publish import ( + DashboardDraft, + PublishedDashboard, + PublishedDashboardLink, + create_published_dashboard_link, + approve_dashboard_draft, + mark_dashboard_data_meaning_changed, + preview_dashboard_draft, + publish_dashboard, + reject_dashboard_draft, + render_published_dashboard_link, + refresh_published_dashboard_view, + revoke_published_dashboard_link, +) +from skill_scripts.design_template_catalog import select_design_template +from skill_scripts.governed_query import GovernedQueryResult, QueryEvidence, approve_query_result + + +def _query_result(sql: str = "SELECT [month],[revenue] FROM [dbo].[SALES]") -> GovernedQueryResult: + return GovernedQueryResult( + evidence=QueryEvidence( + prompt="查詢2026年月營收", + sql=sql, + route="rule", + route_reason="test", + validation_status="OK", + execution_status="OK", + execution_timestamp="2026-05-07T00:00:00+00:00", + returned_columns=["month", "revenue"], + row_count=2, + sample_rows=[ + {"month": "2026-01", "revenue": 1000.0}, + {"month": "2026-02", "revenue": 1500.0}, + ], + ) + ) + + +def _canvas(sql: str = "SELECT [month],[revenue] FROM [dbo].[SALES]") -> DashboardCanvas: + canvas = DashboardCanvas(columns=12, rows=8) + chart = create_dashboard_chart_draft( + approve_query_result(_query_result(sql=sql)), + {"chart_type": "bar", "title": "Monthly Revenue", "x_field": "month", "y_field": "revenue"}, + ) + _ = canvas.add_chart("revenue", chart, x=0, y=0, w=6, h=4) + return canvas + + +def _draft(dashboard_id: str = "dashboard-1", sql: str = "SELECT [month],[revenue] FROM [dbo].[SALES]") -> DashboardDraft: + return DashboardDraft( + dashboard_id=dashboard_id, + canvas_payload=_canvas(sql=sql).to_payload(), + template_selection=select_design_template("soft_operations"), + ) + + +def _published() -> PublishedDashboard: + return publish_dashboard(approve_dashboard_draft(_draft(), approver="analyst-1")) + + +class FakeReadOnlyDashboardDataSource: + def __init__(self, *, fail_view: bool = False, fail_refresh: bool = False) -> None: + self.fail_view: bool = fail_view + self.fail_refresh: bool = fail_refresh + self.calls: list[tuple[str, str, int]] = [] + + def read_published_dashboard(self, published: PublishedDashboard, *, reason: str) -> dict[str, object]: + self.calls.append((reason, published.dashboard_id, published.version)) + if reason == "view" and self.fail_view: + raise RuntimeError("READ_ONLY_REPLICA_TIMEOUT") + if reason == "refresh" and self.fail_refresh: + raise RuntimeError("READ_ONLY_REPLICA_TIMEOUT") + return {"rows": [{"month": "2026-01", "revenue": 1000}], "reason": reason} + + +def test_dashboard_draft_preview_includes_canvas_chart_specs_and_template() -> None: + persisted_canvas = _canvas().to_payload() + reloaded_canvas = load_dashboard_canvas(persisted_canvas) + draft = DashboardDraft( + dashboard_id="dashboard-1", + canvas_payload=reloaded_canvas.to_payload(), + template_selection=select_design_template("soft_operations"), + ) + + preview = preview_dashboard_draft(draft) + + assert preview == { + "dashboard_id": "dashboard-1", + "state": "draft", + "template": { + "template_id": "soft_operations", + "name": "Soft Operations", + "tokens": {"background": "#f8fafc", "surface": "#ffffff", "accent": "#2563eb", "text": "#0f172a"}, + }, + "canvas": persisted_canvas, + } + preview_canvas = cast(dict[str, object], preview["canvas"]) + preview_items = cast(list[dict[str, object]], preview_canvas["items"]) + assert preview_items[0]["chart"] == {"chart_type": "bar", "title": "Monthly Revenue", "x_field": "month", "y_field": "revenue"} + + +def test_published_dashboard_link_token_is_opaque_and_scoped_to_dashboard() -> None: + published = _published() + + link = create_published_dashboard_link(published, expires_at="2026-05-08T00:00:00+00:00") + + assert link.dashboard_id == "dashboard-1" + assert link.token != "dashboard-1" + assert link.token != "dashboard-1:1" + assert len(link.token) >= 32 + assert link.audit_events[-1] == { + "event": "published_dashboard_link_created", + "dashboard_id": "dashboard-1", + "version": 1, + "expires_at": "2026-05-08T00:00:00+00:00", + } + + +def test_valid_published_dashboard_link_renders_view_only_without_authoring_controls() -> None: + published = _published() + link = create_published_dashboard_link(published, expires_at="2026-05-08T00:00:00+00:00") + data_source = FakeReadOnlyDashboardDataSource() + + rendered = render_published_dashboard_link( + token=link.token, + links=[link], + dashboards=[published], + data_source=data_source, + now="2026-05-07T00:00:00+00:00", + ) + + assert rendered["status"] == "ok" + assert rendered["view_only"] is True + assert rendered["authoring_actions"] == [] + dashboard = cast(dict[str, object], rendered["dashboard"]) + assert dashboard["dashboard_id"] == "dashboard-1" + assert dashboard["canvas"] == published.payload["canvas"] + assert rendered["data"] == {"rows": [{"month": "2026-01", "revenue": 1000}], "reason": "view"} + assert data_source.calls == [("view", "dashboard-1", 1)] + + +def test_initial_view_data_source_failure_returns_safe_failure_state() -> None: + published = _published() + link = create_published_dashboard_link(published, expires_at="2026-05-08T00:00:00+00:00") + data_source = FakeReadOnlyDashboardDataSource(fail_view=True) + + rendered = render_published_dashboard_link( + token=link.token, + links=[link], + dashboards=[published], + data_source=data_source, + now="2026-05-07T00:00:00+00:00", + ) + + assert rendered["status"] == "error" + assert rendered["code"] == "DASHBOARD_VIEW_FAILED" + assert rendered["view_only"] is True + assert rendered["authoring_actions"] == [] + assert rendered["dashboard"] is None + assert rendered["data"] is None + events = cast(list[dict[str, object]], rendered["audit_events"]) + assert events[-1] == { + "event": "published_dashboard_view_failed", + "dashboard_id": "dashboard-1", + "version": 1, + "code": "DASHBOARD_VIEW_FAILED", + } + assert data_source.calls == [("view", "dashboard-1", 1)] + + +def test_malformed_link_expiration_is_denied_without_runtime_crash_or_payload_leak() -> None: + published = _published() + link = create_published_dashboard_link(published, expires_at="not-a-timestamp") + data_source = FakeReadOnlyDashboardDataSource() + + rendered = render_published_dashboard_link( + token=link.token, + links=[link], + dashboards=[published], + data_source=data_source, + now="2026-05-07T00:00:00+00:00", + ) + + assert rendered["status"] == "denied" + assert rendered["code"] == "LINK_EXPIRATION_MALFORMED" + assert rendered["dashboard"] is None + assert rendered["data"] is None + events = cast(list[dict[str, object]], rendered["audit_events"]) + assert events[-1]["event"] == "published_dashboard_link_denied_malformed_expiration" + assert data_source.calls == [] + + +def test_revoked_expired_malformed_and_unpublished_links_are_denied_without_payload_leak() -> None: + published = _published() + link = create_published_dashboard_link(published, expires_at="2026-05-08T00:00:00+00:00") + revoked = revoke_published_dashboard_link(link) + expired = create_published_dashboard_link(published, expires_at="2026-05-06T00:00:00+00:00") + data_source = FakeReadOnlyDashboardDataSource() + + denial_cases: list[tuple[str, list[PublishedDashboardLink], str, str]] = [ + ("missing", [], "LINK_TOKEN_MALFORMED", "published_dashboard_link_malformed"), + (revoked.token, [revoked], "LINK_TOKEN_REVOKED", "published_dashboard_link_denied_revoked"), + (expired.token, [expired], "LINK_TOKEN_EXPIRED", "published_dashboard_link_denied_expired"), + (link.token, [link], "PUBLISHED_DASHBOARD_NOT_AVAILABLE", "published_dashboard_link_denied_unpublished"), + ] + + for token, links, code, event_name in denial_cases: + rendered = render_published_dashboard_link(token=token, links=links, dashboards=[], data_source=data_source, now="2026-05-07T00:00:00+00:00") + assert rendered["status"] == "denied" + assert rendered["code"] == code + assert rendered["dashboard"] is None + assert rendered["data"] is None + events = cast(list[dict[str, object]], rendered["audit_events"]) + assert events[-1]["event"] == event_name + + assert data_source.calls == [] + assert revoked.audit_events[-1]["event"] == "published_dashboard_link_revoked" + + +def test_published_dashboard_refresh_uses_read_only_source_with_bounded_interval() -> None: + published = _published() + link = create_published_dashboard_link(published) + data_source = FakeReadOnlyDashboardDataSource() + rendered = render_published_dashboard_link(token=link.token, links=[link], dashboards=[published], data_source=data_source, now="2026-05-07T00:00:00+00:00") + + refreshed = refresh_published_dashboard_view(rendered, published=published, data_source=data_source, refresh_interval_seconds=300) + + assert refreshed["status"] == "ok" + assert refreshed["refresh"] == {"status": "fresh", "interval_seconds": 300} + assert refreshed["data"] == {"rows": [{"month": "2026-01", "revenue": 1000}], "reason": "refresh"} + assert data_source.calls == [("view", "dashboard-1", 1), ("refresh", "dashboard-1", 1)] + + +def test_refresh_policy_rejects_out_of_bounds_intervals_and_reports_safe_failure_state() -> None: + published = _published() + rendered: dict[str, object] = {"status": "ok", "data": {"rows": [{"month": "2026-01", "revenue": 1000}]}, "audit_events": []} + + for interval, code in [(59, "DASHBOARD_REFRESH_INTERVAL_TOO_FAST"), (3601, "DASHBOARD_REFRESH_INTERVAL_TOO_SLOW")]: + try: + _ = refresh_published_dashboard_view(rendered, published=published, data_source=FakeReadOnlyDashboardDataSource(), refresh_interval_seconds=interval) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert code in str(exc) + + failed = refresh_published_dashboard_view( + rendered, + published=published, + data_source=FakeReadOnlyDashboardDataSource(fail_refresh=True), + refresh_interval_seconds=300, + ) + + assert failed["status"] == "ok" + assert failed["data"] == {"rows": [{"month": "2026-01", "revenue": 1000}]} + assert failed["refresh"] == {"status": "stale_error", "interval_seconds": 300, "code": "DASHBOARD_REFRESH_FAILED"} + failed_events = cast(list[dict[str, object]], failed["audit_events"]) + assert failed_events[-1] == { + "event": "published_dashboard_refresh_failed", + "dashboard_id": "dashboard-1", + "version": 1, + "code": "DASHBOARD_REFRESH_FAILED", + } + + +def test_dashboard_draft_requires_final_analyst_approval_before_publish() -> None: + draft = _draft() + + try: + _ = publish_dashboard(draft) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_FINAL_APPROVAL_REQUIRED" in str(exc) + + approved = approve_dashboard_draft(draft, approver="analyst-1") + published = publish_dashboard(approved) + + assert published.dashboard_id == "dashboard-1" + assert published.version == 1 + assert published.payload["state"] == "published" + assert published.audit_events[-1]["event"] == "dashboard_published" + + +def test_published_dashboard_version_is_immutable_after_draft_changes() -> None: + approved = approve_dashboard_draft(_draft(), approver="analyst-1") + published = publish_dashboard(approved) + original_payload = published.payload + changed_draft = DashboardDraft( + dashboard_id="dashboard-1", + canvas_payload=_canvas().to_payload(), + template_selection=select_design_template("ledger_focus"), + ) + + changed_preview = preview_dashboard_draft(changed_draft) + + assert changed_preview["template"] != original_payload["template"] + assert published.payload == original_payload + assert published.payload["version"] == 1 + + +def test_data_meaning_changes_require_reapproval_before_republish() -> None: + approved = approve_dashboard_draft(_draft(), approver="analyst-1") + published = publish_dashboard(approved) + changed = mark_dashboard_data_meaning_changed(approved, reason="sql_changed") + + try: + _ = publish_dashboard(changed, previous=published) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_REAPPROVAL_REQUIRED" in str(exc) + + reapproved = approve_dashboard_draft(changed, approver="analyst-2") + republished = publish_dashboard(reapproved, previous=published) + + assert republished.version == 2 + assert [event["event"] for event in republished.audit_events][-3:] == [ + "dashboard_reapproval_required", + "dashboard_final_approved", + "dashboard_published", + ] + + +def test_rejected_and_invalid_dashboard_publish_transitions_are_refused() -> None: + rejected = reject_dashboard_draft(_draft(), approver="analyst-1", reason="wrong metric") + + try: + _ = approve_dashboard_draft(rejected, approver="analyst-2") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_DRAFT_NOT_APPROVABLE:rejected" in str(exc) + + try: + _ = publish_dashboard(rejected) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_FINAL_APPROVAL_REQUIRED" in str(exc) + + try: + _ = reject_dashboard_draft(_draft(), approver="analyst-1", reason="") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DASHBOARD_REJECTION_REASON_REQUIRED" in str(exc) + + +def test_approval_rejection_publish_and_reapproval_events_are_audited() -> None: + rejected = reject_dashboard_draft(_draft(), approver="analyst-1", reason="wrong metric") + approved = approve_dashboard_draft(_draft(), approver="analyst-1") + published = publish_dashboard(approved) + changed = mark_dashboard_data_meaning_changed(approved, reason="dashboard_data_source_changed") + + assert rejected.audit_events[-1] == { + "event": "dashboard_final_rejected", + "dashboard_id": "dashboard-1", + "state": "rejected", + "approver": "analyst-1", + "reason": "wrong metric", + } + assert approved.audit_events[-1]["event"] == "dashboard_final_approved" + assert published.audit_events[-1] == { + "event": "dashboard_published", + "dashboard_id": "dashboard-1", + "version": 1, + "state": "published", + } + assert changed.audit_events[-1] == { + "event": "dashboard_reapproval_required", + "dashboard_id": "dashboard-1", + "state": "reapproval_required", + "reason": "dashboard_data_source_changed", + } diff --git a/tests/skill_scripts/test_data_dictionary.py b/tests/skill_scripts/test_data_dictionary.py new file mode 100644 index 0000000..ead725f --- /dev/null +++ b/tests/skill_scripts/test_data_dictionary.py @@ -0,0 +1,24 @@ +from skill_scripts.data_dictionary import build_alias_index, build_field_index + + +def test_build_field_index_maps_field_id_to_table_field_list(): + fields = [ + {"TableID": "ACPTA", "ID": "TA001", "FieldName": "憑單單號", "NameVietnam": "Số chứng từ"}, + {"TableID": "PURTA", "ID": "TA001", "FieldName": "採購單號", "NameVietnam": "Số đơn mua"}, + {"TableID": "ACPTA", "ID": "TA002", "FieldName": "憑單日期", "NameVietnam": "Ngày chứng từ"}, + ] + + index = build_field_index(fields) + assert index["TA001"] == ["ACPTA.TA001", "PURTA.TA001"] + assert index["TA002"] == ["ACPTA.TA002"] + + +def test_build_alias_index_includes_lower_cased_id_fieldname_nameviet(): + fields = [ + {"TableID": "ACPTA", "ID": "TA001", "FieldName": "採購單號", "NameVietnam": "So Don Mua"}, + ] + + alias = build_alias_index(fields) + assert alias["ta001"] == ["ACPTA.TA001"] + assert alias["採購單號"] == ["ACPTA.TA001"] + assert alias["so don mua"] == ["ACPTA.TA001"] diff --git a/tests/skill_scripts/test_database_client.py b/tests/skill_scripts/test_database_client.py new file mode 100644 index 0000000..88bb4fc --- /dev/null +++ b/tests/skill_scripts/test_database_client.py @@ -0,0 +1,176 @@ +from pytest import MonkeyPatch + +from skill_scripts.database_client import DatabaseClient, DatabaseConfig + + +def test_database_config_reads_environment_values(monkeypatch: MonkeyPatch): + monkeypatch.setenv("DB_DRIVER", "mssql") + monkeypatch.setenv("DB_CONNECTION_STRING", "Server=127.0.0.1;Database=wferp_test;") + monkeypatch.setenv("DB_ENV", "test") + cfg = DatabaseConfig.from_env() + assert cfg.driver == "mssql" + assert cfg.connection_string.startswith("Server=") + assert cfg.env == "test" + + +def test_database_config_builds_mssql_string_for_sql_auth(): + cfg = DatabaseConfig( + driver="mssql", + connection_string="", + auth_mode="sql_auth", + env="test", + host="127.0.0.1", + port=1433, + database="wferp_test", + username="sa", + password="Passw0rd!", + domain="", + odbc_driver="ODBC Driver 18 for SQL Server", + ) + conn_str = cfg.resolved_connection_string() + assert "server=127.0.0.1:1433;" in conn_str + assert "user=sa;" in conn_str + + +def test_database_config_builds_domain_username_for_mssql(): + cfg = DatabaseConfig( + driver="mssql", + connection_string="", + auth_mode="windows_domain", + env="test", + host="127.0.0.1", + port=1433, + database="wferp_test", + username="alice", + password="secret", + domain="ACME", + odbc_driver="ODBC Driver 18 for SQL Server", + ) + assert "user=ACME\\alice;" in cfg.resolved_connection_string() + + +def test_database_client_health_check_missing_connection(monkeypatch: MonkeyPatch): + monkeypatch.delenv("DB_CONNECTION_STRING", raising=False) + cfg = DatabaseConfig.from_env().with_overrides(connection_string="") + client = DatabaseClient(cfg) + ok, code = client.health_check() + assert ok is False + assert code == "DB_PASSWORD_MISSING" + + +def test_database_client_health_check_accepts_password_from_connection_string(): + cfg = DatabaseConfig( + driver="mssql", + connection_string="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test", + auth_mode="sql_auth", + env="test", + host="127.0.0.1", + port=1433, + database="wferp_test", + username="", + password="", + domain="", + odbc_driver="ODBC Driver 18 for SQL Server", + ) + client = DatabaseClient(cfg) + ok, code = client.health_check() + assert ok is True + assert code == "OK" + + +def test_database_client_execute_readonly_normalizes_driver_errors(monkeypatch: MonkeyPatch): + class _BadCursor: + description: object | None = [("MK002",)] + + def execute(self, sql: str) -> object: + _ = sql + raise ValueError("driver execute failed") + + def fetchall(self) -> list[tuple[object, ...]]: + return [] + + class _BadConnection: + def cursor(self) -> _BadCursor: + return _BadCursor() + + def close(self) -> object: + return None + + cfg = DatabaseConfig( + driver="mssql", + connection_string="server=127.0.0.1:1433;user=sa;password=Passw0rd!;database=wferp_test", + auth_mode="sql_auth", + env="test", + host="127.0.0.1", + port=1433, + database="wferp_test", + username="sa", + password="Passw0rd!", + domain="", + odbc_driver="ODBC Driver 18 for SQL Server", + ) + + client = DatabaseClient(cfg) + monkeypatch.setattr(client, "_connect", lambda: _BadConnection()) + try: + _ = client.execute_readonly("SELECT [MK002] FROM [VPIC1].[dbo].[ACTMK]") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert str(exc) == "DB_EXECUTION_FAILED" + + +def test_database_client_mssql_connect_uses_parsed_connection_fields(monkeypatch: MonkeyPatch): + captured: dict[str, object] = {} + + class _FakeConnection: + def cursor(self) -> object: + class _Cursor: + description: object | None = [("MK002",), ("MK006",)] + + def execute(self, sql: str) -> object: + _ = sql + return None + + def fetchall(self) -> list[tuple[object, ...]]: + return [("2026", 100000.0)] + + return _Cursor() + + def close(self) -> object: + return None + + class _FakePymssql: + @staticmethod + def connect(**kwargs: object) -> object: + captured.update(kwargs) + return _FakeConnection() + + def _fake_import(name: str) -> object: + assert name == "pymssql" + return _FakePymssql + + monkeypatch.setattr("skill_scripts.database_client.importlib.import_module", _fake_import) + + cfg = DatabaseConfig( + driver="mssql", + connection_string="server=127.0.0.1:1433;user=sa;password=Passw0rd!234;database=wferp_test", + auth_mode="sql_auth", + env="test", + host="irrelevant-host", + port=1433, + database="irrelevant-db", + username="irrelevant-user", + password="irrelevant-pass", + domain="", + odbc_driver="ODBC Driver 18 for SQL Server", + ) + client = DatabaseClient(cfg) + + rows = client.execute_readonly("SELECT [MK002],[MK006] FROM [wferp_test].[dbo].[ACTMK]") + + assert captured["server"] == "127.0.0.1" + assert captured["port"] == 1433 + assert captured["user"] == "sa" + assert captured["password"] == "Passw0rd!234" + assert captured["database"] == "wferp_test" + assert rows == [{"MK002": "2026", "MK006": 100000.0}] diff --git a/tests/skill_scripts/test_design_template_catalog.py b/tests/skill_scripts/test_design_template_catalog.py new file mode 100644 index 0000000..f3dcef3 --- /dev/null +++ b/tests/skill_scripts/test_design_template_catalog.py @@ -0,0 +1,154 @@ +from skill_scripts.dashboard_canvas import DashboardCanvas, create_dashboard_chart_draft +from skill_scripts.design_template_catalog import ( + DesignTemplateSelection, + apply_design_template_to_canvas_preview, + list_design_template_options, + select_design_template, +) +from skill_scripts.governed_query import GovernedQueryResult, QueryEvidence, approve_query_result + + +def _query_result() -> GovernedQueryResult: + return GovernedQueryResult( + evidence=QueryEvidence( + prompt="查詢2026年月營收", + sql="SELECT [month],[revenue] FROM [dbo].[SALES]", + route="rule", + route_reason="test", + validation_status="OK", + execution_status="OK", + execution_timestamp="2026-05-07T00:00:00+00:00", + returned_columns=["month", "revenue"], + row_count=2, + sample_rows=[ + {"month": "2026-01", "revenue": 1000.0}, + {"month": "2026-02", "revenue": 1500.0}, + ], + ) + ) + + +def _canvas() -> DashboardCanvas: + canvas = DashboardCanvas(columns=12, rows=8) + chart = create_dashboard_chart_draft( + approve_query_result(_query_result()), + {"chart_type": "bar", "title": "Monthly Revenue", "x_field": "month", "y_field": "revenue"}, + ) + _ = canvas.add_chart("revenue", chart, x=0, y=0, w=6, h=4) + return canvas + + +def test_catalog_presents_deterministic_generic_design_template_options() -> None: + first_payload = list_design_template_options() + second_payload = list_design_template_options() + + assert first_payload == second_payload + assert first_payload == { + "templates": [ + { + "template_id": "executive_contrast", + "name": "Executive Contrast", + "description": "High-contrast dashboard styling for dense operational reviews.", + "preview_tokens": {"background": "#0f172a", "surface": "#1e293b", "accent": "#38bdf8"}, + }, + { + "template_id": "soft_operations", + "name": "Soft Operations", + "description": "Light, spacious dashboard styling for day-to-day process monitoring.", + "preview_tokens": {"background": "#f8fafc", "surface": "#ffffff", "accent": "#2563eb"}, + }, + { + "template_id": "ledger_focus", + "name": "Ledger Focus", + "description": "Structured dashboard styling for finance and tabular control views.", + "preview_tokens": {"background": "#f5f1e8", "surface": "#fffaf0", "accent": "#b45309"}, + }, + ] + } + + +def test_catalog_options_do_not_use_source_brand_names_as_official_names() -> None: + brand_names = {"apple", "stripe", "linear", "notion", "vercel", "shopify", "slack", "github", "voltagent"} + + for option in list_design_template_options()["templates"]: + official_name = str(option["name"]).lower() + assert all(brand_name not in official_name for brand_name in brand_names) + + +def test_allowed_design_template_can_be_selected_for_dashboard_preview() -> None: + selection = select_design_template("soft_operations") + preview = apply_design_template_to_canvas_preview(_canvas(), selection) + + assert isinstance(selection, DesignTemplateSelection) + assert selection.to_payload() == { + "template_id": "soft_operations", + "name": "Soft Operations", + "tokens": {"background": "#f8fafc", "surface": "#ffffff", "accent": "#2563eb", "text": "#0f172a"}, + } + assert preview == { + "template": selection.to_payload(), + "canvas": { + "columns": 12, + "rows": 8, + "items": [ + { + "chart_id": "revenue", + "x": 0, + "y": 0, + "w": 6, + "h": 4, + "chart": {"chart_type": "bar", "title": "Monthly Revenue", "x_field": "month", "y_field": "revenue"}, + } + ], + }, + } + + +def test_switching_design_templates_replaces_the_active_template() -> None: + selection = select_design_template("executive_contrast") + selection = select_design_template("ledger_focus", current_selection=selection) + preview = apply_design_template_to_canvas_preview(_canvas(), selection) + + assert selection.template_id == "ledger_focus" + assert preview["template"] == { + "template_id": "ledger_focus", + "name": "Ledger Focus", + "tokens": {"background": "#f5f1e8", "surface": "#fffaf0", "accent": "#b45309", "text": "#292524"}, + } + assert "executive_contrast" not in str(preview) + + +def test_invalid_design_template_selection_is_refused_with_clear_error() -> None: + for template_id in ("", "stripe", "unknown_template"): + try: + _ = select_design_template(template_id) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert f"DESIGN_TEMPLATE_NOT_ALLOWED:{template_id}" in str(exc) + + +def test_preview_refuses_unselected_or_invalid_design_templates() -> None: + cases: list[object] = [None, object(), select_design_template("soft_operations").to_payload()] + + for selection in cases: + try: + _ = apply_design_template_to_canvas_preview(_canvas(), selection) + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert "DESIGN_TEMPLATE_SELECTION_REQUIRED" in str(exc) + + +def test_preview_canonicalizes_manually_constructed_design_template_selection() -> None: + forged_selection = DesignTemplateSelection( + template_id="soft_operations", + name="Stripe", + tokens={"background": "#000000", "surface": "#000000", "accent": "#000000", "text": "#000000"}, + ) + + preview = apply_design_template_to_canvas_preview(_canvas(), forged_selection) + + assert preview["template"] == { + "template_id": "soft_operations", + "name": "Soft Operations", + "tokens": {"background": "#f8fafc", "surface": "#ffffff", "accent": "#2563eb", "text": "#0f172a"}, + } diff --git a/tests/skill_scripts/test_dynamic_design_brief.py b/tests/skill_scripts/test_dynamic_design_brief.py new file mode 100644 index 0000000..2c67291 --- /dev/null +++ b/tests/skill_scripts/test_dynamic_design_brief.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief + + +def _expense_package(columns: list[str] | None = None) -> dict[str, object]: + return { + "schema_version": "wferp.report-package.v1", + "prompt": "查詢 2026 年費用明細", + "catalog_guardrail": "financial-control", + "report_type": "管理摘要", + "data_profile": { + "row_count": 12, + "columns": columns or ["department", "expense_amount", "budget_amount"], + }, + "datasets": {"columns": columns or ["department", "expense_amount", "budget_amount"]}, + "aggregates": {"expense_amount_sum": 42000}, + } + + +def test_financial_control_expense_package_uses_default_chart_and_table_recipes(): + brief = build_design_brief(_expense_package()) + + assert list(brief) == [ + "schema_version", + "report_intent", + "catalog_guardrail", + "target_audience", + "layout_recipe", + "chart_recipe", + "table_recipe", + "interaction_recipe", + "visual_direction", + "embedded_data_policy", + ] + assert brief["schema_version"] == "wferp.design-brief.v1" + assert brief["layout_recipe"]["mode"] == "kpi-first-dashboard" + assert [chart["type"] for chart in brief["chart_recipe"]] == ["combo", "stacked-bar", "bar"] + assert all({"id", "type", "purpose"}.issubset(chart) for chart in brief["chart_recipe"]) + assert isinstance(brief["table_recipe"], list) + assert brief["table_recipe"][0]["id"] + assert brief["table_recipe"][0]["type"] == "data-table" + assert brief["table_recipe"][0]["features"] == [ + "filter", + "sort", + "drilldown", + "column_visibility", + ] + assert brief["embedded_data_policy"]["mode"] == "smart-tiered" + assert validate_design_brief(brief)["valid"] is True + + +def test_period_column_adds_line_chart_before_default_expense_charts(): + brief = build_design_brief(_expense_package(["month", "department", "expense_amount"])) + + assert [chart["type"] for chart in brief["chart_recipe"]] == [ + "line", + "combo", + "stacked-bar", + "bar", + ] + assert brief["chart_recipe"][0]["id"] + assert brief["chart_recipe"][0]["purpose"] + assert validate_design_brief(brief)["valid"] is True + + +def test_validate_design_brief_rejects_chart_without_purpose(): + brief = build_design_brief( + _expense_package(), + user_overrides={"chart_recipe": [{"type": "combo"}]}, + ) + + result = validate_design_brief(brief) + assert result["valid"] is False + assert "chart_recipe[0].purpose" in result["errors"] + + +def test_validate_design_brief_rejects_empty_required_top_level_value(): + brief = build_design_brief(_expense_package()) + brief["report_intent"] = {} + + result = validate_design_brief(brief) + + assert result["valid"] is False + assert "report_intent" in result["errors"] + + +def test_validate_design_brief_rejects_wrong_schema_version(): + brief = build_design_brief(_expense_package(), user_overrides={"schema_version": "bad"}) + + result = validate_design_brief(brief) + + assert result["valid"] is False + assert "schema_version" in result["errors"] + + +def test_validate_design_brief_rejects_non_mapping_layout_recipe(): + brief = build_design_brief(_expense_package(), user_overrides={"layout_recipe": "bad"}) + + result = validate_design_brief(brief) + + assert result["valid"] is False + assert "layout_recipe" in result["errors"] + + +def test_validate_design_brief_rejects_non_string_catalog_guardrail(): + brief = build_design_brief(_expense_package(), user_overrides={"catalog_guardrail": []}) + + result = validate_design_brief(brief) + + assert result["valid"] is False + assert "catalog_guardrail" in result["errors"] diff --git a/tests/skill_scripts/test_excel_intake.py b/tests/skill_scripts/test_excel_intake.py new file mode 100644 index 0000000..f10f228 --- /dev/null +++ b/tests/skill_scripts/test_excel_intake.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +from skill_scripts.excel_intake import ( + WorkbookRequirement, + build_excel_confirmation_payload, + parse_excel_requirement, +) + + +def _column_name(index: int) -> str: + name = "" + while index: + index, remainder = divmod(index - 1, 26) + name = chr(65 + remainder) + name + return name + + +def _sheet_xml(rows: list[list[str | tuple[str, str]]]) -> str: + rendered_rows = [] + for row_index, row in enumerate(rows, start=1): + cells = [] + for column_index, value in enumerate(row, start=1): + cell_ref = f"{_column_name(column_index)}{row_index}" + if isinstance(value, tuple) and value[0] == "formula": + formula = value[1].lstrip("=") + cells.append( + f'{escape(formula)}' + ) + elif isinstance(value, tuple) and value[0] == "shared_formula": + cells.append(f'') + else: + cells.append( + f'{escape(str(value))}' + ) + rendered_rows.append(f'{"".join(cells)}') + return ( + '' + '' + f'{"".join(rendered_rows)}' + "" + ) + + +def _write_requirement_workbook(path: Path) -> None: + sheets = [ + ( + "需求欄位", + [ + ["表格", "欄位", "顯示名稱", "說明"], + ["ACPTA", "TA003", "單據日期", "傳票日期"], + ["ACPTB", "TB017", "未稅金額", "本幣未稅金額"], + ["ACPTB", "TB018", "稅額", "本幣稅額"], + ], + ), + ( + "自訂公式", + [ + ["欄位名稱", "公式", "說明"], + ["總額", ("formula", "=未稅金額+稅額"), "含稅金額"], + ["稅率", ("formula", "=稅額/未稅金額"), "稅額除以未稅"], + ], + ), + ( + "管理報表", + [ + ["輸出欄位", "來源欄位", "公式"], + ["單據日期", "單據日期", ""], + ["總額", "總額", ("formula", "=未稅金額+稅額")], + ["毛利率", "", ("formula", "=毛利/營收")], + ], + ), + ] + + workbook_sheets = [] + rels = [] + overrides = [] + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + '' + + "".join( + f'' + for index, _ in enumerate(sheets, start=1) + ) + + "", + ) + archive.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + for index, (name, rows) in enumerate(sheets, start=1): + workbook_sheets.append( + f'' + ) + rels.append( + f'' + ) + overrides.append(_sheet_xml(rows)) + archive.writestr(f"xl/worksheets/sheet{index}.xml", _sheet_xml(rows)) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'{"".join(workbook_sheets)}' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + f'{"".join(rels)}' + "", + ) + + +def _write_management_requirement_workbook(path: Path) -> None: + sheets = [ + ( + "需求欄位", + [ + ["表格", "欄位", "顯示名稱", "說明"], + ["CMSMV", "MV001", "部門代號", "部門代號"], + ["CMSMV", "MV002", "部門名稱", "部門名稱"], + ["ACTMA", "MA001", "費用科目", "費用科目"], + ["ACPTB", "TB017", "金額", "本幣金額"], + ], + ), + ( + "自訂公式", + [ + ["欄位名稱", "公式", "說明"], + ["費用占比", ("formula", "=金額/總金額"), "金額占全部費用比例"], + ["異常旗標", ("formula", '=IF(金額>預算,"超支","正常")'), "超預算提示"], + ], + ), + ( + "明細", + [ + ["部門代號", "部門名稱", "費用科目", "金額"], + ["D001", "管理部", "旅費", "1000"], + ], + ), + ( + "管理報表", + [ + ["部門費用管理報表"], + [""], + ["指標", "數值"], + ["部門數", ("formula", "=COUNTA(明細!A:A)-1")], + [""], + ["總金額", ("formula", "=SUM(明細!D:D)")], + ], + ), + ] + + workbook_sheets = [] + rels = [] + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + '' + + "".join( + f'' + for index, _ in enumerate(sheets, start=1) + ) + + "", + ) + archive.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + for index, (name, rows) in enumerate(sheets, start=1): + workbook_sheets.append( + f'' + ) + rels.append( + f'' + ) + archive.writestr(f"xl/worksheets/sheet{index}.xml", _sheet_xml(rows)) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'{"".join(workbook_sheets)}' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + f'{"".join(rels)}' + "", + ) + + +def test_reads_required_database_fields_from_workbook(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + + requirement = parse_excel_requirement(workbook) + + assert [(field.table, field.field, field.display_name) for field in requirement.database_fields] == [ + ("ACPTA", "TA003", "單據日期"), + ("ACPTB", "TB017", "未稅金額"), + ("ACPTB", "TB018", "稅額"), + ] + + +def test_reads_user_formula_fields(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + + requirement = parse_excel_requirement(workbook) + + assert [(field.name, field.formula) for field in requirement.formula_fields] == [ + ("總額", "=未稅金額+稅額"), + ("稅率", "=稅額/未稅金額"), + ] + + +def test_extracts_formula_references(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + + requirement = parse_excel_requirement(workbook) + + lineage = {item.field_name: item.references for item in requirement.formula_lineage} + assert lineage["總額"] == [ + {"name": "未稅金額", "source": "database-backed"}, + {"name": "稅額", "source": "database-backed"}, + ] + assert lineage["稅率"] == [ + {"name": "稅額", "source": "database-backed"}, + {"name": "未稅金額", "source": "database-backed"}, + ] + + +def test_extracts_formula_backed_references(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + with zipfile.ZipFile(workbook, "a", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "xl/worksheets/sheet2.xml", + _sheet_xml( + [ + ["欄位名稱", "公式", "說明"], + ["總額", ("formula", "=未稅金額+稅額"), "含稅金額"], + ["費用率", ("formula", "=總額/未稅金額"), "公式欄位除以未稅"], + ] + ), + ) + + requirement = parse_excel_requirement(workbook) + + lineage = {item.field_name: item.references for item in requirement.formula_lineage} + assert {"name": "總額", "source": "formula-backed"} in lineage["費用率"] + + +def test_detects_formula_columns_without_database_source(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + + requirement = parse_excel_requirement(workbook) + + assert any("毛利" in warning and "unresolved" in warning for warning in requirement.warnings) + assert any("營收" in warning and "unresolved" in warning for warning in requirement.warnings) + + +def test_warns_about_empty_or_shared_formula_cells(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + with zipfile.ZipFile(workbook, "a", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "xl/worksheets/sheet2.xml", + _sheet_xml( + [ + ["欄位名稱", "公式", "說明"], + ["總額", ("shared_formula", "1"), "copied formula"], + ] + ), + ) + + requirement = parse_excel_requirement(workbook) + + assert any("總額" in warning and "shared formula" in warning for warning in requirement.warnings) + + +def test_builds_confirmation_payload_in_chinese(tmp_path: Path): + workbook = tmp_path / "requirement.xlsx" + _write_requirement_workbook(workbook) + + requirement = parse_excel_requirement(workbook) + payload = build_excel_confirmation_payload(requirement) + + assert set(payload) == {"資料庫欄位", "使用者公式欄位", "報表輸出欄位", "需使用者確認"} + assert payload["資料庫欄位"][0]["顯示名稱"] == "單據日期" + assert payload["使用者公式欄位"][0]["公式"] == "=未稅金額+稅額" + assert payload["報表輸出欄位"][1]["輸出欄位"] == "總額" + assert payload["需使用者確認"] + + +def test_builds_excel_requirement_contract_with_formula_lineage_and_report_outputs(tmp_path: Path): + workbook = tmp_path / "management_requirement.xlsx" + _write_management_requirement_workbook(workbook) + + requirement_dict = parse_excel_requirement(workbook).to_dict() + + assert requirement_dict["source_type"] == "excel" + assert [field["label"] for field in requirement_dict["db_fields"]] == [ + "部門代號", + "部門名稱", + "費用科目", + "金額", + ] + assert requirement_dict["db_fields"][0] == { + "label": "部門代號", + "required": True, + "matched_schema_field": None, + } + assert requirement_dict["formula_fields"][0]["name"] == "費用占比" + assert requirement_dict["formula_fields"][0]["formula"] == "=金額/總金額" + assert requirement_dict["formula_fields"][0]["dependencies"] == ["金額", "總金額"] + assert all( + field["requires_user_confirmation"] is True + for field in requirement_dict["formula_fields"] + ) + assert requirement_dict["report_outputs"][0]["sheet"] == "管理報表" + assert {"address": "B6", "formula": "=SUM(明細!D:D)"} in requirement_dict[ + "report_outputs" + ][0]["cells"] + assert requirement_dict["warnings"] == [] + + +def test_workbook_requirement_keeps_legacy_constructor_without_report_outputs(): + requirement = WorkbookRequirement( + database_fields=[], + formula_fields=[], + report_fields=[], + formula_lineage=[], + warnings=[], + ) + + assert requirement.to_dict()["report_outputs"] == [] diff --git a/tests/skill_scripts/test_excel_workbook_exporter.py b/tests/skill_scripts/test_excel_workbook_exporter.py new file mode 100644 index 0000000..568430a --- /dev/null +++ b/tests/skill_scripts/test_excel_workbook_exporter.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from skill_scripts.excel_workbook_exporter import export_workbook +from skill_scripts.report_harness import ReportHarness +from skill_scripts.cli_report_harness import main + + +def test_export_workbook_writes_real_xlsx_and_evidence(tmp_path: Path): + payload = { + "sheets": [ + { + "name": "Detail", + "columns": ["department", "amount"], + "rows": [{"department": "A", "amount": 1000}], + "formula_strategy": "hybrid", + }, + { + "name": "Formula Notes", + "columns": ["field", "logic"], + "rows": [{"field": "amount", "logic": "from SQLite enriched data"}], + "formula_strategy": "value-only", + }, + ] + } + + result = export_workbook(payload, tmp_path / "report.xlsx", evidence_path=tmp_path / "excel-evidence.json") + + assert result["status"] == "exported" + assert result["workbook_path"].endswith("report.xlsx") + assert result["sheets"][0]["name"] == "Detail" + assert result["sheets"][0]["row_count"] == 1 + assert result["verification"]["xlsx_contains_workbook_xml"] is True + assert (tmp_path / "report.xlsx").is_file() + assert (tmp_path / "excel-evidence.json").is_file() + with zipfile.ZipFile(tmp_path / "report.xlsx") as archive: + assert "xl/workbook.xml" in archive.namelist() + + +def test_export_excel_workbook_cli_updates_state(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="build workbook") + payload_path = tmp_path / "payload.json" + payload_path.write_text( + json.dumps( + { + "sheets": [ + { + "name": "Detail", + "columns": ["department", "amount"], + "rows": [{"department": "A", "amount": 1000}], + } + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + result = main( + [ + "export-excel-workbook", + "--run-dir", + str(harness.run_dir), + "--payload", + str(payload_path), + ] + ) + + assert result == 0 + state = harness.state() + assert state["final_xlsx_path"].endswith("report.xlsx") + assert state["excel_workbook_evidence"]["status"] == "exported" + assert (harness.run_dir / "report" / "delivery" / "report.xlsx").is_file() diff --git a/tests/skill_scripts/test_execution_validator.py b/tests/skill_scripts/test_execution_validator.py new file mode 100644 index 0000000..073cbf2 --- /dev/null +++ b/tests/skill_scripts/test_execution_validator.py @@ -0,0 +1,64 @@ +from skill_scripts.execution_validator import ( + AggregateExpectation, + ExecutionExpectation, + execute_and_validate, + validate_execution_result, +) + + +class _FakeDbClient: + def __init__(self, rows): + self._rows = rows + + def execute_readonly(self, sql: str): + assert sql.startswith("SELECT") + return self._rows + + +def test_validate_execution_result_checks_required_columns(): + rows = [{"MK002": "2026", "MK006": 1000.0}] + ok, code = validate_execution_result( + rows, + ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ) + assert ok is True + assert code == "OK" + + +def test_validate_execution_result_detects_aggregate_mismatch(): + rows = [{"MK006": 100.0}, {"MK006": 200.0}] + ok, code = validate_execution_result( + rows, + ExecutionExpectation( + min_rows=1, + aggregates=[AggregateExpectation(operation="sum", column="MK006", expected_value=500.0)], + ), + ) + assert ok is False + assert code == "AGGREGATE_MISMATCH" + + +def test_validate_execution_result_detects_missing_aggregate_column(): + rows = [{"MK002": "2026"}] + ok, code = validate_execution_result( + rows, + ExecutionExpectation( + min_rows=1, + aggregates=[AggregateExpectation(operation="sum", column="MK006", expected_value=0.0)], + ), + ) + assert ok is False + assert code == "MISSING_AGGREGATE_COLUMN" + + +def test_execute_and_validate_uses_db_client_and_returns_rows(): + rows = [{"MK002": "2026", "MK006": 1000.0}] + fake = _FakeDbClient(rows) + ok, code, actual_rows = execute_and_validate( + "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK]", + fake, + ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ) + assert ok is True + assert code == "OK" + assert actual_rows == rows diff --git a/tests/skill_scripts/test_expense_analysis_postgres_e2e.py b/tests/skill_scripts/test_expense_analysis_postgres_e2e.py new file mode 100644 index 0000000..850a1d4 --- /dev/null +++ b/tests/skill_scripts/test_expense_analysis_postgres_e2e.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os + +import pytest + +from tests.skill_scripts.expense_report_fixture import EXPECTED_COLUMNS, run_expense_analysis_postgres_e2e + + +pytestmark = pytest.mark.skipif( + os.getenv("WFERP_RUN_POSTGRES_E2E") != "1", + reason="set WFERP_RUN_POSTGRES_E2E=1 and start the Docker PostgreSQL fixture to run this test", +) + + +def test_expense_analysis_postgres_e2e_quantitative_acceptance(): + result = run_expense_analysis_postgres_e2e( + os.getenv("WFERP_POSTGRES_E2E_CONTAINER", "wferp-postgres-e2e") + ) + + assert result["row_count"] == 6 + assert result["columns"] == EXPECTED_COLUMNS + assert result["aggregates"]["total_amount"] == 120000 + assert result["aggregates"]["total_budget"] == 100000 + assert result["aggregates"]["variance_amount"] == 20000 + assert result["aggregates"]["max_expense_ratio"] == 0.35 + assert result["excluded_rows"]["non_2026"] == 1 + assert result["excluded_rows"]["non_expense_account"] == 1 + assert result["sql_safety"]["readonly"] is True + assert result["sql_safety"]["blocked_keywords"] == [] diff --git a/tests/skill_scripts/test_expense_analysis_sqlite_e2e.py b/tests/skill_scripts/test_expense_analysis_sqlite_e2e.py new file mode 100644 index 0000000..f9218ff --- /dev/null +++ b/tests/skill_scripts/test_expense_analysis_sqlite_e2e.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from tests.skill_scripts import expense_report_fixture +from tests.skill_scripts.expense_report_fixture import EXPECTED_COLUMNS + + +def test_expense_analysis_sqlite_e2e_quantitative_acceptance(tmp_path): + runner = getattr(expense_report_fixture, "run_expense_analysis_sqlite_e2e", None) + + assert callable(runner) + + result = runner(tmp_path) + + assert result["row_count"] == 6 + assert result["columns"] == EXPECTED_COLUMNS + assert result["aggregates"]["total_amount"] == 120000 + assert result["aggregates"]["total_budget"] == 100000 + assert result["aggregates"]["variance_amount"] == 20000 + assert result["aggregates"]["max_expense_ratio"] == 0.35 + assert result["excluded_rows"]["non_2026"] == 1 + assert result["excluded_rows"]["non_expense_account"] == 1 + assert result["sql_safety"]["readonly"] is True + assert result["sql_safety"]["blocked_keywords"] == [] + assert result["sqlite_manifest"]["raw_row_count"] >= 1 + assert result["sqlite_manifest"]["enriched_row_count"] == result["sqlite_manifest"]["raw_row_count"] + assert result["ignored_lookup_rows"] >= 0 + assert result["sqlite_manifest"]["cleanup_status"] == "active" diff --git a/tests/skill_scripts/test_formula_sqlite_translator.py b/tests/skill_scripts/test_formula_sqlite_translator.py new file mode 100644 index 0000000..5ae4eeb --- /dev/null +++ b/tests/skill_scripts/test_formula_sqlite_translator.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import pytest + +from skill_scripts.formula_sqlite_translator import translate_formula + + +def test_translate_arithmetic_cell_formula_to_sqlite_expression(): + column_map = { + "Q": '"original_debit"', + "R": '"original_credit"', + "AB": '"local_signed"', + "AN": "4.5958", + } + + assert translate_formula("=Q2-R2", column_map) == '("original_debit" - "original_credit")' + assert translate_formula("=AN2*AB2", column_map) == '(4.5958 * "local_signed")' + + +def test_translate_mid_and_if_left_formula(): + column_map = {"F": '"voucher_no"', "A": '"account_code"'} + + assert translate_formula("=MID(F2,6,6)", column_map) == 'SUBSTR("voucher_no", 6, 6)' + assert translate_formula('=IF(LEFT(A2,2)="57","57",LEFT(A2,1))', column_map) == ( + 'CASE WHEN SUBSTR("account_code", 1, 2) = \'57\' THEN \'57\' ' + 'ELSE SUBSTR("account_code", 1, 1) END' + ) + + +def test_translate_formula_allows_safe_raw_alias_qualified_identifiers(): + column_map = {"F": 'raw."voucher_no"', "A": 'raw."account_code"'} + + assert translate_formula("=MID(F2,6,6)", column_map) == 'SUBSTR(raw."voucher_no", 6, 6)' + assert translate_formula('=IF(LEFT(A2,2)="57","57",LEFT(A2,1))', column_map) == ( + 'CASE WHEN SUBSTR(raw."account_code", 1, 2) = \'57\' THEN \'57\' ' + 'ELSE SUBSTR(raw."account_code", 1, 1) END' + ) + + +def test_unsupported_formula_returns_structured_error_when_not_strict(): + result = translate_formula("=INDIRECT(A2)", {"A": '"account_code"'}, strict=False) + + assert result == { + "status": "unsupported", + "function": "INDIRECT", + "formula": "=INDIRECT(A2)", + } + + +def test_unsupported_formula_raises_when_strict(): + with pytest.raises(ValueError, match="Unsupported Excel formula function: INDIRECT"): + translate_formula("=INDIRECT(A2)", {"A": '"account_code"'}) + + +def test_translate_formula_rejects_arbitrary_sql_payload(): + with pytest.raises(ValueError, match="Unsupported Excel formula"): + translate_formula('="x"; DROP TABLE raw_ledger; --', {}) + + result = translate_formula("=A2+1", {"A": '"account_code"'}, strict=False) + + assert result == { + "status": "unsupported", + "function": "UNKNOWN", + "formula": "=A2+1", + } + + +def test_translate_formula_rejects_unsafe_column_map_values(): + with pytest.raises(ValueError, match="Unsafe SQLite mapping"): + translate_formula("=A2-B2", {"A": 'raw."amount"; DROP TABLE x; --', "B": '"credit"'}) diff --git a/tests/skill_scripts/test_governed_query.py b/tests/skill_scripts/test_governed_query.py new file mode 100644 index 0000000..5c7e4bf --- /dev/null +++ b/tests/skill_scripts/test_governed_query.py @@ -0,0 +1,280 @@ +import json +from collections.abc import Callable +from dataclasses import asdict, dataclass +from decimal import Decimal + +from pytest import MonkeyPatch + +from skill_scripts.execution_validator import AggregateExpectation, ExecutionExpectation +from skill_scripts.governed_query import ( + approve_query_result, + reject_query_result, + require_query_result_approved_for_chart_spec, + run_governed_query, +) +from skill_scripts.sql_router import RoutingOptions + + +Row = dict[str, object] + + +def _bundle() -> dict[str, list[dict[str, str]]]: + return { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + {"DB": "VPIC1.dbo.", "TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "TableNameViet": "", "ModuleID": "ACT"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán"}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ"}, + ], + "index_keys": [ + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + +@dataclass(frozen=True) +class _FakeDbConfig: + env: str + + +class _FakeDbClient: + config: _FakeDbConfig + _rows: list[Row] + + def __init__(self, rows: list[Row] | None = None, env: str = "test") -> None: + self._rows = rows or [{"MK002": "2026", "MK006": 1000.0}] + self.config = _FakeDbConfig(env=env) + self.executed_sql: list[str] = [] + + def execute_readonly(self, sql: str) -> list[Row]: + assert sql.startswith("SELECT") + self.executed_sql.append(sql) + return self._rows + + def health_check(self) -> tuple[bool, str]: + return True, "OK" + + +class _FakeCustomDbClientNoConfig: + def execute_readonly(self, sql: str) -> list[Row]: + _ = sql + return [{"MK002": "2026", "MK006": 1000.0}] + + def health_check(self) -> tuple[bool, str]: + return True, "OK" + + +def _assert_runtime_error(fn: Callable[[], object], expected_substring: str) -> None: + try: + _ = fn() + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert expected_substring in str(exc) + + +def test_run_governed_query_returns_ready_for_approval(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation( + required_columns=["MK002", "MK006"], + min_rows=1, + aggregates=[], + ), + ), + db_client=_FakeDbClient(), + sample_rows_limit=1, + ) + assert result.approval_state == "ready_for_analyst_approval" + assert result.evidence.validation_status == "OK" + assert result.evidence.execution_status == "OK" + assert result.evidence.returned_columns == ["MK002", "MK006"] + assert result.evidence.row_count == 1 + assert result.evidence.sample_rows == [{"MK002": "2026", "MK006": 1000.0}] + assert result.evidence.required_columns == ["MK002", "MK006"] + assert result.evidence.route == "llm" + assert result.evidence.execution_timestamp + + +def test_run_governed_query_includes_configured_aggregate_checks(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation( + required_columns=["MK002", "MK006"], + min_rows=1, + aggregates=[AggregateExpectation(operation="sum", column="MK006", expected_value=1000.0)], + ), + ), + db_client=_FakeDbClient(), + ) + assert result.evidence.aggregate_checks == [ + {"operation": "sum", "column": "MK006", "expected_value": 1000.0, "tolerance": 0.0} + ] + + +def test_run_governed_query_returns_json_serializable_sample_rows(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(rows=[{"MK002": "2026", "MK006": Decimal("1000.50")}]), + ) + assert result.evidence.sample_rows == [{"MK002": "2026", "MK006": 1000.5}] + json.dumps(asdict(result), ensure_ascii=False) + + +def test_run_governed_query_rejects_unsafe_sql_before_execution(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "DELETE FROM [VPIC1].[dbo].[ACTMK]", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + db_client = _FakeDbClient() + _assert_runtime_error( + lambda: run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + llm_repair_attempts=0, + ), + db_client=db_client, + ), + "LLM_REPAIR_FAILED:NON_SELECT_INTENT", + ) + assert db_client.executed_sql == [] + + +def test_run_governed_query_requires_db_client() -> None: + _assert_runtime_error( + lambda: run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions(mode="rule"), + db_client=None, + ), + "DB_CLIENT_REQUIRED", + ) + + +def test_run_governed_query_blocks_client_without_env_config(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeCustomDbClientNoConfig(), + ), + "DB_ENV_NOT_TEST", + ) + + +def test_approve_query_result_unlocks_chart_spec(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(), + ) + _assert_runtime_error(lambda: require_query_result_approved_for_chart_spec(result), "QUERY_RESULT_NOT_APPROVED") + approved = approve_query_result(result) + require_query_result_approved_for_chart_spec(approved) + assert approved.approval_state == "approved_for_chart_spec" + + +def test_reject_query_result_requires_reason(monkeypatch: MonkeyPatch) -> None: + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(), + ) + _assert_runtime_error(lambda: reject_query_result(result, ""), "REJECTION_REASON_REQUIRED") + rejected = reject_query_result(result, "資料不符預期") + assert rejected.approval_state == "rejected" + assert rejected.rejection_reason == "資料不符預期" diff --git a/tests/skill_scripts/test_harness_state_machine.py b/tests/skill_scripts/test_harness_state_machine.py new file mode 100644 index 0000000..3752314 --- /dev/null +++ b/tests/skill_scripts/test_harness_state_machine.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from skill_scripts.harness_state_machine import ( + GATE_DEFINITIONS, + USER_STEP_MAPPING, + GateBlockedError, + initialize_state_machine, + evaluate_gate, + assert_can_advance, +) +from skill_scripts.report_harness_state import create_report_run, load_run_state + + +def test_initialize_state_machine_adds_required_workflow_fields(tmp_path: Path): + state = create_report_run(tmp_path, run_id="demo-run", prompt="產出報表") + initialized = initialize_state_machine(state) + + assert initialized["current_user_step"] == 1 + assert initialized["current_internal_phase"] == 0 + assert initialized["user_step_mapping"] == USER_STEP_MAPPING + assert initialized["user_step_mapping"] == { + 1: { + "title": "Source-to-Output Logic", + "label": "source_to_output_logic", + "phases": [0, 1, 2, 3], + }, + 2: {"title": "SQL Query", "label": "sql_query", "phases": [4, 5]}, + 3: { + "title": "Data Result and Report Design", + "label": "data_result_and_report_design", + "phases": [6, 7, 8, 9], + }, + 4: {"title": "Final Delivery", "label": "final_delivery", "phases": [10, 11, 12]}, + } + assert initialized["gate_status"]["phase_4_sql_review"] == { + "status": "blocked", + "user_step": 2, + "required_artifacts": ["sql/query.sql", "checkpoints/02_sql_review.json"], + "required_validators": ["sql_safety_reviewer", "schema_mapping_reviewer"], + "confirmation": { + "required": True, + "checkpoint_id": "sql_review", + "payload_hash": None, + }, + "allowed_next_actions": ["execute_select", "request_changes"], + "blocking_reason": [ + "missing_artifact:sql/query.sql", + "missing_artifact:checkpoints/02_sql_review.json", + "missing_validator:sql_safety_reviewer", + "missing_validator:schema_mapping_reviewer", + "missing_confirmation:sql_review", + ], + } + assert initialized["blocking_repair_request"] is None + assert initialized["delivery_status"] == "not_ready" + assert initialized["allowed_next_actions"] == ["prepare_source_logic"] + assert initialized["phase_status"] == { + "phase_3_field_formula": "blocked", + "phase_4_sql_review": "blocked", + "phase_6_data_preview": "blocked", + "phase_12_delivery": "blocked", + } + assert initialized["required_artifacts"] == { + gate: definition["required_artifacts"] for gate, definition in GATE_DEFINITIONS.items() + } + assert initialized["required_validators"] == { + gate: definition["required_validators"] for gate, definition in GATE_DEFINITIONS.items() + } + + +def test_create_report_run_persists_state_machine_fields(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="產出報表") + + state = load_run_state(tmp_path / "demo-run") + + assert state["current_user_step"] == 1 + assert state["current_internal_phase"] == 0 + assert "phase_3_field_formula" in state["gate_status"] + assert state["confirmation_identity"] == {} + assert "phase_status" in state + assert "required_artifacts" in state + assert "required_validators" in state + assert state["gate_status"]["phase_3_field_formula"]["confirmation"]["checkpoint_id"] == ( + "field_formula_classification" + ) + + +def test_gate_definitions_match_required_plan_contract(): + assert GATE_DEFINITIONS == { + "phase_3_field_formula": { + "user_step": 1, + "required_artifacts": ["checkpoints/01b_field_formula_classification.json"], + "required_validators": [ + "requirement_understanding_reviewer", + "schema_mapping_reviewer", + ], + "confirmation_checkpoint": "field_formula_classification", + "allowed_next_actions": ["generate_sql", "request_changes"], + }, + "phase_4_sql_review": { + "user_step": 2, + "required_artifacts": ["sql/query.sql", "checkpoints/02_sql_review.json"], + "required_validators": ["sql_safety_reviewer", "schema_mapping_reviewer"], + "confirmation_checkpoint": "sql_review", + "allowed_next_actions": ["execute_select", "request_changes"], + }, + "phase_6_data_preview": { + "user_step": 3, + "required_artifacts": [ + "checkpoints/03a_raw_data_preview.json", + "checkpoints/03b_enriched_data_preview.json", + ], + "required_validators": ["db_execution_reviewer", "data_preview_reviewer"], + "confirmation_checkpoint": "enriched_data_preview", + "allowed_next_actions": ["render_report_design", "request_changes"], + }, + "phase_12_delivery": { + "user_step": 4, + "required_artifacts": [ + "report/delivery/report.html", + "report/delivery/report.xlsx", + "review/final-review.json", + ], + "required_validators": [ + "report_content_reviewer", + "visual_taste_reviewer", + "react_technical_reviewer", + "delivery_reviewer", + ], + "confirmation_checkpoint": "final_review", + "allowed_next_actions": ["deliver", "request_changes"], + }, + } + + +def test_evaluate_gate_returns_complete_when_gate_inputs_are_ready(): + state = initialize_state_machine( + { + "artifact_status": { + "sql/query.sql": "complete", + "checkpoints/02_sql_review.json": "complete", + }, + "validator_results": [ + {"role": "sql_safety_reviewer", "status": "pass"}, + {"role": "schema_mapping_reviewer", "status": "pass"}, + ], + "confirmation_identity": { + "sql_review": {"confirmation_id": "confirm-1", "payload_hash": "hash-1"} + }, + } + ) + + evaluation = evaluate_gate(state, "phase_4_sql_review") + + assert evaluation["status"] == "complete" + assert evaluation["confirmation"]["checkpoint_id"] == "sql_review" + assert evaluation["confirmation"]["payload_hash"] == "hash-1" + assert evaluation["blocking_reason"] == [] + + +def test_evaluate_gate_preserves_checkpoint_hash_before_confirmation(): + state = initialize_state_machine( + { + "artifact_status": { + "checkpoints/01b_field_formula_classification.json": "complete", + }, + "validator_results": [ + {"role": "requirement_understanding_reviewer", "status": "pass"}, + {"role": "schema_mapping_reviewer", "status": "pass"}, + ], + "checkpoints": [ + { + "checkpoint": "field_formula_classification", + "checkpoint_id": "field_formula_classification", + "file": "checkpoints/01b_field_formula_classification.json", + "payload_hash": "checkpoint-hash", + } + ], + } + ) + + evaluation = evaluate_gate(state, "phase_3_field_formula") + + assert evaluation["status"] == "blocked" + assert evaluation["blocking_reason"] == ["missing_confirmation:field_formula_classification"] + assert evaluation["confirmation"]["payload_hash"] == "checkpoint-hash" + + +def test_initialize_state_machine_merges_existing_gate_status_without_resetting_progress(): + initialized = initialize_state_machine( + { + "gate_status": { + "phase_4_sql_review": { + "status": "complete", + "user_step": 99, + "required_artifacts": ["wrong"], + "required_validators": ["wrong"], + "allowed_next_actions": ["wrong"], + "confirmation": { + "required": False, + "checkpoint_id": "wrong", + "payload_hash": "existing-hash", + }, + "blocking_reason": [], + "custom_metadata": {"reviewed_at": "2026-06-20T00:00:00Z"}, + } + } + } + ) + + sql_gate = initialized["gate_status"]["phase_4_sql_review"] + assert sql_gate["status"] == "complete" + assert sql_gate["confirmation"]["payload_hash"] == "existing-hash" + assert sql_gate["confirmation"]["required"] is True + assert sql_gate["confirmation"]["checkpoint_id"] == "sql_review" + assert sql_gate["custom_metadata"] == {"reviewed_at": "2026-06-20T00:00:00Z"} + assert sql_gate["user_step"] == 2 + assert sql_gate["required_artifacts"] == ["sql/query.sql", "checkpoints/02_sql_review.json"] + assert sql_gate["required_validators"] == ["sql_safety_reviewer", "schema_mapping_reviewer"] + assert sql_gate["allowed_next_actions"] == ["execute_select", "request_changes"] + assert "phase_12_delivery" in initialized["gate_status"] + assert initialized["phase_status"]["phase_4_sql_review"] == "complete" + + +@pytest.mark.parametrize("validator_status", ["complete", True]) +def test_evaluate_gate_does_not_treat_non_pass_validator_status_as_passed(validator_status): + state = initialize_state_machine( + { + "artifact_status": { + "sql/query.sql": "complete", + "checkpoints/02_sql_review.json": "complete", + }, + "validator_results": [ + {"role": "sql_safety_reviewer", "status": validator_status}, + {"role": "schema_mapping_reviewer", "status": "pass"}, + ], + "confirmation_identity": { + "sql_review": {"confirmation_id": "confirm-1", "payload_hash": "hash-1"} + }, + } + ) + + evaluation = evaluate_gate(state, "phase_4_sql_review") + + assert evaluation["status"] == "blocked" + assert evaluation["blocking_reason"] == ["missing_validator:sql_safety_reviewer"] + + +def test_assert_can_advance_raises_when_gate_is_blocked_by_repair_request(): + state = initialize_state_machine( + { + "artifact_status": { + "sql/query.sql": "complete", + "checkpoints/02_sql_review.json": "complete", + }, + "validator_results": [ + {"validator": "sql_safety_reviewer", "status": "pass"}, + {"role": "schema_mapping_reviewer", "status": "pass"}, + ], + "confirmation_identity": { + "sql_review": {"confirmation_id": "confirm-1", "payload_hash": "hash-1"} + }, + "blocking_repair_request": {"gate": "phase_4_sql_review", "reason": "revise SQL"}, + } + ) + + with pytest.raises(GateBlockedError, match="phase_4_sql_review"): + assert_can_advance(state, "phase_4_sql_review") diff --git a/tests/skill_scripts/test_html_self_validator.py b/tests/skill_scripts/test_html_self_validator.py new file mode 100644 index 0000000..0343560 --- /dev/null +++ b/tests/skill_scripts/test_html_self_validator.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +from skill_scripts.html_self_validator import validate_single_html_static + + +def test_static_validator_accepts_self_contained_html(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ +

    費用分析

    + + """, + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result == {"valid": True, "errors": [], "network_references": []} + + +def test_static_validator_rejects_external_network_and_credentials(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ + + password=secret""", + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result["valid"] is False + assert "external_script" in result["errors"] + assert "credentials" in result["errors"] + assert result["network_references"] == ["https://cdn.example/app.js"] + + +def test_static_validator_rejects_fetch_websocket_link_and_missing_package(tmp_path: Path): + html = tmp_path / "report.html" + html.write_text( + """ + + """, + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result["valid"] is False + assert "external_stylesheet" in result["errors"] + assert "network_fetch" in result["errors"] + assert "websocket" in result["errors"] + assert "missing_package" in result["errors"] + assert result["network_references"] == ["https://cdn.example/app.css"] + + +def test_static_validator_rejects_external_script_and_link_attribute_order_variants( + tmp_path: Path, +): + html = tmp_path / "report.html" + html.write_text( + """ + + + """, + encoding="utf-8", + ) + + result = validate_single_html_static(html) + + assert result["valid"] is False + assert "external_script" in result["errors"] + assert "external_stylesheet" in result["errors"] + assert result["network_references"] == [ + "https://cdn.example/app.js", + "https://cdn.example/app.css", + ] diff --git a/tests/skill_scripts/test_llm_sql_generator.py b/tests/skill_scripts/test_llm_sql_generator.py new file mode 100644 index 0000000..a19ce00 --- /dev/null +++ b/tests/skill_scripts/test_llm_sql_generator.py @@ -0,0 +1,134 @@ +import json +from types import SimpleNamespace + +from skill_scripts.llm_sql_generator import build_llm_prompt, call_llm, parse_llm_response + + +def test_parse_llm_response_extracts_sql_and_tables(): + payload = '{"sql":"SELECT TOP 10 * FROM [VPIC1].[dbo].[ACTMK]","used_tables":["ACTMK"],"assumptions":["year filter"],"confidence":0.82}' + out = parse_llm_response(payload) + assert out["sql"].startswith("SELECT") + assert out["used_tables"] == ["ACTMK"] + assert out["assumptions"] == ["year filter"] + assert 0.0 <= out["confidence"] <= 1.0 + + +def test_build_llm_prompt_contains_user_prompt_and_context(): + prompt = build_llm_prompt("查詢預算明細", {"tables": [{"TableID": "ACTMK"}]}) + assert "查詢預算明細" in prompt + assert "ACTMK" in prompt + + +def test_call_llm_mock_provider(monkeypatch): + mock = { + "sql": "SELECT TOP 5 * FROM [VPIC1].[dbo].[ACTMK]", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.9, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock, ensure_ascii=False)) + raw = call_llm(provider="mock", model="unused", prompt_text="anything", timeout_sec=1.0) + parsed = parse_llm_response(raw) + assert parsed["sql"].startswith("SELECT TOP 5") + + +def test_call_llm_none_provider_raises(): + try: + call_llm(provider="none", model="unused", prompt_text="x") + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert str(exc) == "LLM_PROVIDER_NOT_CONFIGURED" + + +def test_parse_llm_response_accepts_markdown_fence(): + fenced = """```json +{"sql":"SELECT TOP 1 * FROM [VPIC1].[dbo].[ACTMK]","used_tables":["ACTMK"],"assumptions":[],"confidence":0.8} +```""" + parsed = parse_llm_response(fenced) + assert parsed["sql"].startswith("SELECT TOP 1") + assert parsed["used_tables"] == ["ACTMK"] + + +def test_call_llm_opencode_provider_reads_text_event(monkeypatch): + stdout = "\n".join( + [ + json.dumps({"type": "step_start"}), + json.dumps( + { + "type": "text", + "part": { + "text": '{"sql":"SELECT TOP 3 * FROM [VPIC1].[dbo].[ACTMK]","used_tables":["ACTMK"],"assumptions":[],"confidence":0.77}' + }, + } + ), + json.dumps({"type": "step_finish"}), + ] + ) + + def _fake_run(command, capture_output, text, check, timeout): + assert command[:3] == ["opencode", "run", "--format"] + assert command[3] == "json" + return SimpleNamespace(returncode=0, stdout=stdout) + + monkeypatch.setattr("skill_scripts.llm_sql_generator.subprocess.run", _fake_run) + + raw = call_llm(provider="opencode", model="none", prompt_text="anything", timeout_sec=1.0) + parsed = parse_llm_response(raw) + assert parsed["sql"].startswith("SELECT TOP 3") + + +def test_call_llm_codex_provider_uses_codex_exec_oauth_path(monkeypatch): + payload = { + "sql": "SELECT TOP 7 * FROM [VPIC1].[dbo].[ACTMK]", + "used_tables": ["ACTMK"], + "assumptions": ["codex oauth provider"], + "confidence": 0.88, + } + + def _fake_run(command, capture_output, text, check, timeout): + assert command[:2] == ["codex", "exec"] + assert "--ephemeral" in command + assert "--sandbox" in command + assert "read-only" in command + assert "--output-last-message" in command + assert "opencode" not in command + output_path = command[command.index("--output-last-message") + 1] + with open(output_path, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=False)) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("skill_scripts.llm_sql_generator.subprocess.run", _fake_run) + + raw = call_llm(provider="codex", model="none", prompt_text="anything", timeout_sec=1.0) + parsed = parse_llm_response(raw) + assert parsed["sql"].startswith("SELECT TOP 7") + assert parsed["used_tables"] == ["ACTMK"] + + +def test_call_llm_codex_provider_passes_long_prompt_outside_command_line(monkeypatch): + long_prompt = "請分類欄位\n" + ("欄位摘要" * 10000) + payload = { + "sql": "SELECT TOP 1 * FROM [VPIC1].[dbo].[ACTMK]", + "used_tables": ["ACTMK"], + "assumptions": ["long prompt"], + "confidence": 0.8, + } + + def _fake_run(command, capture_output, text, check, timeout): + assert long_prompt not in command + assert sum(len(str(part)) for part in command) < 2000 + short_prompt = command[-1] + assert "Read the complete prompt from this UTF-8 file" in short_prompt + prompt_path = short_prompt.split("PROMPT_FILE=", 1)[1].strip() + with open(prompt_path, encoding="utf-8") as handle: + assert handle.read() == long_prompt + output_path = command[command.index("--output-last-message") + 1] + with open(output_path, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=False)) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("skill_scripts.llm_sql_generator.subprocess.run", _fake_run) + + raw = call_llm(provider="codex", model="none", prompt_text=long_prompt, timeout_sec=1.0) + parsed = parse_llm_response(raw) + assert parsed["assumptions"] == ["long prompt"] diff --git a/tests/skill_scripts/test_llm_workbook_classifier.py b/tests/skill_scripts/test_llm_workbook_classifier.py new file mode 100644 index 0000000..67646cc --- /dev/null +++ b/tests/skill_scripts/test_llm_workbook_classifier.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tests.skill_scripts.test_workbook_classifier import _classification_workbook +from skill_scripts.llm_workbook_classifier import ( + build_llm_table_first_classification_prompt, + classify_workbook_with_llm, +) + + +def test_llm_prompt_requires_table_first_resolution(tmp_path: Path): + prompt = build_llm_table_first_classification_prompt( + user_prompt="產生每月損益比較表", + workbook_path=tmp_path / "req.xlsx", + primary_sheet="明細帳", + workbook_columns=[{"excel_column": "A", "excel_header": "科目名稱", "formula": ""}], + schema_context={"candidate_tables_for_llm_to_choose_from": []}, + ) + + assert "table-first" in prompt + assert "first identify the most likely WFERP table group" in prompt + assert "Do not map a column by header alone" in prompt + assert "candidate_tables" in prompt + assert "relationship_path" in prompt + + +def test_classify_workbook_with_llm_uses_candidate_tables_before_field_mapping( + tmp_path: Path, monkeypatch +): + workbook = tmp_path / "req.xlsx" + _classification_workbook(workbook) + llm_payload = { + "candidate_tables": [ + { + "table_id": "ACTML", + "table_name": "分類帳檔", + "reason": "明細帳交易列最可能在分類帳檔", + "confidence": 0.93, + }, + { + "table_id": "ACTMA", + "table_name": "會計科目資料檔", + "reason": "科目名稱需由科目主檔取得", + "confidence": 0.88, + }, + ], + "column_mappings": [ + { + "excel_column": "A", + "excel_header": "科目編號", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML006", + "fields": [{"table_id": "ACTML", "column_id": "ML006", "business_meaning": "明細科目編號"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.92, + "reason": "LLM selected ACTML as ledger table first, then mapped account code.", + "risks": [], + }, + { + "excel_column": "J", + "excel_header": "BU", + "classification": "excel_enrichment_field", + "processing_location": "sqlite_enrichment", + "source_expression": "=VLOOKUP(A2,對照表!A:B,2,0)", + "fields": [], + "relationship_path": [], + "lookup_sheet": "對照表", + "confidence": 0.9, + "reason": "Workbook lookup formula, not formal DB SQL.", + "risks": [], + }, + ], + "assumptions": ["mock table-first classifier response"], + "confidence": 0.9, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(llm_payload, ensure_ascii=False)) + + result = classify_workbook_with_llm( + workbook, + source_dir="_Source", + primary_sheet="明細帳", + user_prompt="產生每月損益比較表", + llm_provider="mock", + llm_model="mock", + ) + + assert result["resolver_strategy"] == "llm_table_first_schema_relationship_resolver" + assert result["llm_status"] == "used" + assert [table["table_id"] for table in result["candidate_tables"]][:2] == ["ACTML", "ACTMA"] + by_header = {column["excel_header"]: column for column in result["columns"]} + assert by_header["科目編號"]["field_metadata"][0]["table_id"] == "ACTML" + assert by_header["科目編號"]["field_metadata"][0]["column_id"] == "ML006" + assert by_header["BU"]["classification"] == "excel_enrichment_field" + assert by_header["BU"]["lookup_sheet"] == "對照表" diff --git a/tests/skill_scripts/test_metadata_validator.py b/tests/skill_scripts/test_metadata_validator.py new file mode 100644 index 0000000..50385b5 --- /dev/null +++ b/tests/skill_scripts/test_metadata_validator.py @@ -0,0 +1,60 @@ +from skill_scripts.metadata_validator import validate_metadata_references + + +def _bundle(): + return { + "tables": [ + {"TableID": "ACTMK"}, + {"TableID": "ACTMJ"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002"}, + {"TableID": "ACTMK", "ID": "MK006"}, + {"TableID": "ACTMJ", "ID": "MJ002"}, + ], + } + + +def test_validate_metadata_references_rejects_unknown_table(): + ok, code = validate_metadata_references( + "SELECT [MK002] FROM [VPIC1].[dbo].[ACTXX]", + _bundle(), + ) + assert ok is False + assert code == "UNKNOWN_TABLE" + + +def test_validate_metadata_references_rejects_unbracketed_table_reference(): + ok, code = validate_metadata_references( + "SELECT MK002 FROM ACTMK", + _bundle(), + ) + assert ok is False + assert code == "TABLE_REFERENCE_FORMAT_INVALID" + + +def test_validate_metadata_references_rejects_unknown_column(): + ok, code = validate_metadata_references( + "SELECT [MK999] FROM [VPIC1].[dbo].[ACTMK]", + _bundle(), + ) + assert ok is False + assert code == "UNKNOWN_COLUMN" + + +def test_validate_metadata_references_accepts_known_references(): + ok, code = validate_metadata_references( + "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK]", + _bundle(), + ) + assert ok is True + assert code == "OK" + + +def test_validate_metadata_references_rejects_qualified_cross_table_column_mismatch(): + ok, code = validate_metadata_references( + "SELECT [ACTMK].[MJ002] FROM [VPIC1].[dbo].[ACTMK] JOIN [VPIC1].[dbo].[ACTMJ] ON [ACTMK].[MK002] = [ACTMJ].[MJ002]", + _bundle(), + ) + assert ok is False + assert code == "UNKNOWN_COLUMN_FOR_TABLE" diff --git a/tests/skill_scripts/test_postgres_readonly_adapter.py b/tests/skill_scripts/test_postgres_readonly_adapter.py new file mode 100644 index 0000000..a7b614b --- /dev/null +++ b/tests/skill_scripts/test_postgres_readonly_adapter.py @@ -0,0 +1,113 @@ +import pytest + +from skill_scripts.postgres_readonly_adapter import ( + PostgresReadonlyAdapterError, + translate_sqlserver_select_to_postgres, +) + + +def test_translates_bracketed_schema_identifiers(): + sql = "SELECT * FROM [DSCSYS].[dbo].[ACPTA]" + + translated = translate_sqlserver_select_to_postgres(sql) + + assert translated == 'SELECT * FROM dbo."ACPTA"' + + +def test_translates_any_database_bracketed_schema_identifiers(): + sql = "SELECT * FROM [VPIC1].[dbo].[ACPTA]" + + translated = translate_sqlserver_select_to_postgres(sql) + + assert translated == 'SELECT * FROM dbo."ACPTA"' + + +def test_translates_top_to_limit(): + sql = "SELECT TOP 20 * FROM [DSCSYS].[dbo].[ACPTA] ORDER BY TA003 DESC" + + translated = translate_sqlserver_select_to_postgres(sql) + + assert translated == 'SELECT * FROM dbo."ACPTA" ORDER BY "TA003" DESC LIMIT 20' + + +def test_translates_isnull_to_coalesce(): + sql = "SELECT * FROM [DSCSYS].[dbo].[ACPTA] WHERE ISNULL(TA018, '') <> 'Y'" + + translated = translate_sqlserver_select_to_postgres(sql) + + assert "COALESCE" in translated + assert "ISNULL" not in translated + assert '"TA018"' in translated + + +def test_preserves_readonly_select_semantics(): + sql = """ +SELECT + detail.TB014 AS expense_department, + SUM(detail.TB017 + detail.TB018) AS total_amount +FROM [DSCSYS].[dbo].[ACPTA] header +JOIN [DSCSYS].[dbo].[ACPTB] detail + ON header.TA001 = detail.TB001 +WHERE header.TA003 >= '20260101' +GROUP BY detail.TB014 +ORDER BY SUM(detail.TB017 + detail.TB018) DESC +""" + + translated = translate_sqlserver_select_to_postgres(sql) + + assert translated.lstrip().upper().startswith("SELECT") + assert 'dbo."ACPTA" header' in translated + assert 'detail."TB014" AS expense_department' in translated + assert 'SUM(detail."TB017" + detail."TB018") AS total_amount' in translated + + +@pytest.mark.parametrize( + "sql", + [ + "INSERT INTO dbo.ACPTA VALUES ('X')", + "UPDATE dbo.ACPTA SET TA024 = 'Y'", + "DELETE FROM dbo.ACPTA", + "DROP TABLE dbo.ACPTA", + "ALTER TABLE dbo.ACPTA ADD x int", + "EXEC dbo.proc", + "MERGE dbo.ACPTA AS target USING dbo.ACPTB AS source ON 1 = 1", + "TRUNCATE TABLE dbo.ACPTA", + "CREATE TABLE dbo.bad(id int)", + "GRANT SELECT ON dbo.ACPTA TO public", + "REVOKE SELECT ON dbo.ACPTA FROM public", + ], +) +def test_rejects_non_select_before_translation(sql): + with pytest.raises(PostgresReadonlyAdapterError): + translate_sqlserver_select_to_postgres(sql) + + +def test_rejects_semicolon_chained_statements(): + with pytest.raises(PostgresReadonlyAdapterError): + translate_sqlserver_select_to_postgres( + "SELECT * FROM [DSCSYS].[dbo].[ACPTA]; SELECT * FROM [DSCSYS].[dbo].[ACPTB]" + ) + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM [DSCSYS].[dbo].[ACPTA] -- comment", + "SELECT * FROM [DSCSYS].[dbo].[ACPTA] /* comment */", + ], +) +def test_rejects_comments(sql): + with pytest.raises(PostgresReadonlyAdapterError): + translate_sqlserver_select_to_postgres(sql) + + +def test_rejects_sql_server_extended_procedures(): + with pytest.raises(PostgresReadonlyAdapterError): + translate_sqlserver_select_to_postgres("SELECT * FROM xp_cmdshell") + + +def test_rejects_select_into_writes(): + with pytest.raises(PostgresReadonlyAdapterError): + translate_sqlserver_select_to_postgres( + "SELECT TA001 INTO dbo.copied_vouchers FROM [DSCSYS].[dbo].[ACPTA]" + ) diff --git a/tests/skill_scripts/test_prompt_sql_consistency.py b/tests/skill_scripts/test_prompt_sql_consistency.py new file mode 100644 index 0000000..c933c48 --- /dev/null +++ b/tests/skill_scripts/test_prompt_sql_consistency.py @@ -0,0 +1,28 @@ +from skill_scripts.prompt_sql_consistency import validate_prompt_sql_consistency + + +def test_validate_prompt_sql_consistency_detects_year_mismatch(): + ok, code = validate_prompt_sql_consistency( + "查詢2026年的工程預算明細", + "SELECT * FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2025'", + ) + assert ok is False + assert code == "YEAR_MISMATCH" + + +def test_validate_prompt_sql_consistency_detects_top_mismatch(): + ok, code = validate_prompt_sql_consistency( + "top 10 查詢預算", + "SELECT TOP 20 * FROM [VPIC1].[dbo].[ACTMK]", + ) + assert ok is False + assert code == "TOP_MISMATCH" + + +def test_validate_prompt_sql_consistency_accepts_matched_constraints(): + ok, code = validate_prompt_sql_consistency( + "查詢2026年的工程預算明細前 20 筆", + "SELECT TOP 20 * FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + ) + assert ok is True + assert code == "OK" diff --git a/tests/skill_scripts/test_query_approval_chart_spec_e2e.py b/tests/skill_scripts/test_query_approval_chart_spec_e2e.py new file mode 100644 index 0000000..a5d5be2 --- /dev/null +++ b/tests/skill_scripts/test_query_approval_chart_spec_e2e.py @@ -0,0 +1,195 @@ +"""E2E-003: Analyst Approval gate before Chart Spec (issue #33). + +Focused slice that proves the existing query-approval gate behavior for the +``查詢2026年的工程預算明細`` prompt: + +* AC1 - A governed query result is produced from the deterministic 2026 + engineering-budget scenario via the existing CLI/library surface. +* AC2 - Attempting to create a Chart Spec from the unapproved governed query + result fails with the existing query-approval gate. +* AC3 - After Analyst Approval, the same governed query result can author a + bar Chart Spec using ``MK006`` as the budget value field. +* AC4 - Refusal and approved paths are recorded as compact behavioral + assertions; raw command logs are not embedded. +* AC5 - This test is additive and does not weaken Chart Spec, governed-query, + or Dashboard Canvas assertions exercised by the broader MVP E2E. + +Both Chart Spec entry points that depend on +``require_query_result_approved_for_chart_spec`` are exercised here so neither +wrapper can silently weaken the gate: +``skill_scripts.chart_spec.author_chart_spec`` and +``skill_scripts.dashboard_canvas.create_dashboard_chart_draft``. +""" + +from __future__ import annotations + +import json +from typing import Protocol, cast + +from pytest import MonkeyPatch + +import skill_scripts.governed_query as governed_query_module +from skill_scripts.chart_spec import ChartSpec, author_chart_spec +from skill_scripts.dashboard_canvas import ( + DashboardChartDraft, + create_dashboard_chart_draft, +) +from skill_scripts.execution_validator import ExecutionExpectation +from skill_scripts.governed_query import ( + GovernedQueryResult, + approve_query_result, +) +from skill_scripts.sql_router import RoutingOptions + + +PROMPT: str = "查詢2026年的工程預算明細" +EXPECTED_GATE_ERROR_CODE: str = "QUERY_RESULT_NOT_APPROVED" + + +def _bundle() -> dict[str, list[dict[str, str]]]: + return { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + { + "DB": "VPIC1.dbo.", + "TableID": "ACTMK", + "TableName": "科目/部門預算單身檔", + "TableNameViet": "", + "ModuleID": "ACT", + }, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán"}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ"}, + ], + "index_keys": [ + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + +class _FakeDbConfig: + def __init__(self, env: str = "test") -> None: + self.env: str = env + + +class _FakeDbClient: + def __init__(self) -> None: + self.config: _FakeDbConfig = _FakeDbConfig(env="test") + self.executed_sql: list[str] = [] + + def execute_readonly(self, sql: str) -> list[dict[str, object]]: + self.executed_sql.append(sql) + return [ + {"MK002": "2026", "MK006": 150000.0}, + {"MK002": "2026", "MK006": 150000.0}, + {"MK002": "2026", "MK006": 150000.0}, + ] + + def health_check(self) -> tuple[bool, str]: + return True, "OK" + + +class _RunGovernedQuery(Protocol): + def __call__( + self, + prompt: str, + bundle: dict[str, list[dict[str, str]]], + options: RoutingOptions, + db_client: _FakeDbClient, + sample_rows_limit: int = 5, + ) -> GovernedQueryResult: ... + + +def _routing_options() -> RoutingOptions: + return RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation( + required_columns=["MK002", "MK006"], + min_rows=1, + ), + ) + + +def _bar_chart_payload_with_mk006_value_field() -> dict[str, object]: + return { + "chart_type": "bar", + "title": "2026 工程預算明細", + "x_field": "MK002", + "y_field": "MK006", + "number_format": {"field": "MK006", "style": "currency", "currency": "TWD"}, + } + + +def _produce_governed_query_result(monkeypatch: MonkeyPatch) -> GovernedQueryResult: + monkeypatch.setenv( + "LLM_MOCK_RESPONSE", + json.dumps( + { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + ) + run_governed_query = cast(_RunGovernedQuery, governed_query_module.run_governed_query) + return run_governed_query( + prompt=PROMPT, + bundle=_bundle(), + options=_routing_options(), + db_client=_FakeDbClient(), + ) + + +def test_unapproved_query_result_blocks_chart_spec_authoring(monkeypatch: MonkeyPatch) -> None: + """AC1 + AC2 + AC4: gate refuses Chart Spec authoring before approval.""" + query_result = _produce_governed_query_result(monkeypatch) + + assert query_result.approval_state == "ready_for_analyst_approval" + assert query_result.evidence.returned_columns == ["MK002", "MK006"] + assert query_result.evidence.row_count == 3 + + try: + _ = author_chart_spec(query_result, _bar_chart_payload_with_mk006_value_field()) + raise AssertionError("expected RuntimeError from author_chart_spec") + except RuntimeError as exc: + assert str(exc) == EXPECTED_GATE_ERROR_CODE + + try: + _ = create_dashboard_chart_draft(query_result, _bar_chart_payload_with_mk006_value_field()) + raise AssertionError("expected RuntimeError from create_dashboard_chart_draft") + except RuntimeError as exc: + assert str(exc) == EXPECTED_GATE_ERROR_CODE + + +def test_approved_query_result_authors_mk006_bar_chart_spec(monkeypatch: MonkeyPatch) -> None: + """AC1 + AC3 + AC4 + AC5: approved result authors a bar Chart Spec on MK006.""" + query_result = _produce_governed_query_result(monkeypatch) + approved = approve_query_result(query_result) + + assert approved.approval_state == "approved_for_chart_spec" + assert approved.evidence == query_result.evidence + + spec = author_chart_spec(approved, _bar_chart_payload_with_mk006_value_field()) + assert isinstance(spec, ChartSpec) + assert spec.chart_type == "bar" + assert spec.title == "2026 工程預算明細" + assert spec.x_field == "MK002" + assert spec.y_field == "MK006" + assert spec.number_format == {"field": "MK006", "style": "currency", "currency": "TWD"} + + draft = create_dashboard_chart_draft(approved, _bar_chart_payload_with_mk006_value_field()) + assert isinstance(draft, DashboardChartDraft) + assert draft.chart.chart_type == "bar" + assert draft.chart.x_field == "MK002" + assert draft.chart.y_field == "MK006" + + preview = draft.preview + assert preview["chart_type"] == "bar" + assert preview["x_field"] == "MK002" + assert preview["y_field"] == "MK006" diff --git a/tests/skill_scripts/test_relationship_graph.py b/tests/skill_scripts/test_relationship_graph.py new file mode 100644 index 0000000..66ce002 --- /dev/null +++ b/tests/skill_scripts/test_relationship_graph.py @@ -0,0 +1,49 @@ +from skill_scripts.relationship_graph import build_primary_key_map, infer_relationships + + +def test_build_primary_key_map_only_primary_rows(): + index_keys = [ + {"TableName": "ACPTA", "IndexColumnName": "TA001+TA002", "isPrimaryKey": "1"}, + {"TableName": "ACPTA", "IndexColumnName": "TA003", "isPrimaryKey": "0"}, + {"TableName": "ACPTB", "IndexColumnName": "TB001+TB002+TB003", "isPrimaryKey": "1"}, + ] + pk_map = build_primary_key_map(index_keys) + assert pk_map == {"ACPTA": ["TA001", "TA002"], "ACPTB": ["TB001", "TB002", "TB003"]} + + +def test_infer_relationships_header_detail_medium_confidence(): + index_keys = [ + {"TableName": "ACPTA", "IndexColumnName": "TA001+TA002", "isPrimaryKey": "1"}, + {"TableName": "ACPTB", "IndexColumnName": "TB001+TB002+TB003", "isPrimaryKey": "1"}, + ] + edges = infer_relationships([], index_keys) + assert edges + edge = edges[0] + assert edge["from_table"] == "ACPTA" + assert edge["to_table"] == "ACPTB" + assert edge["from_columns"] == ["TA001", "TA002"] + assert edge["to_columns"] == ["TB001", "TB002"] + assert edge["confidence"] == "medium" + assert edge["cardinality"] == "1:N" + assert "prefix" in edge["reason"].lower() + + +def test_infer_relationships_prefix_only_never_high_confidence(): + index_keys = [ + {"TableName": "ABCZZ", "IndexColumnName": "ZZ001", "isPrimaryKey": "1"}, + {"TableName": "ABCAA", "IndexColumnName": "AA001", "isPrimaryKey": "1"}, + ] + edges = infer_relationships([], index_keys) + assert edges + assert all(edge["confidence"] != "high" for edge in edges) + + +def test_infer_relationships_returns_confidence_cardinality_domain(): + index_keys = [ + {"TableName": "AAA", "IndexColumnName": "AA001", "isPrimaryKey": "1"}, + {"TableName": "AAB", "IndexColumnName": "AB001", "isPrimaryKey": "1"}, + ] + edges = infer_relationships([], index_keys) + for edge in edges: + assert edge["confidence"] in {"high", "medium", "low"} + assert edge["cardinality"] in {"1:1", "1:N", "N:N", "unknown"} diff --git a/tests/skill_scripts/test_report_catalog.py b/tests/skill_scripts/test_report_catalog.py new file mode 100644 index 0000000..465aebf --- /dev/null +++ b/tests/skill_scripts/test_report_catalog.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from skill_scripts.report_catalog import ( + build_report_selection_payload, + get_report_design_defaults, + load_report_design_catalog, + list_report_designs, + list_report_types, +) + + +REQUIRED_PROFILE_IDS = [ + "financial-control", + "executive-summary", + "detail-ledger", + "exception-audit", + "operations-review", + "trend-briefing", +] + + +REQUIRED_PRODUCT_KEYS = { + "id", + "label", + "best_for", + "required_sections", + "default_components", + "chart_policy", + "table_policy", + "kpi_policy", + "tone", + "layout_density", + "validator_focus", +} + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def test_report_catalog_contains_required_report_types(): + names = {item["name"] for item in list_report_types()} + + assert { + "明細查詢表", + "彙總統計表", + "趨勢分析表", + "比較分析表", + "異常稽核表", + "管理摘要", + "完整分析報告", + }.issubset(names) + + +def test_report_designs_are_loadable_markdown(): + designs = list_report_designs() + + ids = {design["id"] for design in designs} + assert ids == set(REQUIRED_PROFILE_IDS) + assert all(Path(design["path"]).suffix == ".md" for design in designs) + + +def test_design_catalog_requires_all_product_metadata(): + catalog = load_report_design_catalog(repo_root() / "report_designs") + + assert catalog.profile_ids == REQUIRED_PROFILE_IDS + financial = catalog.get_profile("financial-control") + assert set(financial) >= REQUIRED_PRODUCT_KEYS + assert financial["label"] == "財務控制" + assert financial["best_for"] == ["費用分析", "預算差異", "異常控管"] + assert financial["required_sections"] == [ + "executive-summary", + "kpi-overview", + "trend", + "detail-table", + "recommendations", + ] + assert financial["default_components"] == [ + "KpiGrid", + "ChartBlock", + "DataTable", + "InsightBlock", + "RecommendationList", + ] + assert financial["chart_policy"] == {"preferred": ["bar", "stacked-bar", "combo"], "avoid": ["pie"]} + assert financial["table_policy"] == { + "density": "compact", + "summary_rows": True, + "conditional_formatting": True, + } + assert financial["kpi_policy"] == {"include_variance": True, "include_budget_ratio": True} + assert financial["tone"] == "管理控制、明確、可追責" + assert financial["layout_density"] == "dense" + assert financial["validator_focus"] == [ + "aggregate_consistency", + "variance_explanation", + "exception_visibility", + ] + + +def test_each_design_declares_required_product_metadata(): + designs = list_report_designs() + + for design in designs: + assert set(design) >= REQUIRED_PRODUCT_KEYS + for key in REQUIRED_PRODUCT_KEYS: + assert design[key], design["id"] + + +def test_default_options_include_chart_table_analysis_recommendation_flags(): + payload = build_report_selection_payload() + + assert payload["default_options"] == { + "include_chart": True, + "include_table": True, + "include_analysis": True, + "include_recommendations": True, + } + assert any(item["name"] == "管理摘要" for item in payload["report_types"]) + assert any(item["id"] == "financial-control" for item in payload["report_designs"]) + + +def test_selected_design_defaults_are_exposed_for_scaffold(): + defaults = get_report_design_defaults("financial-control") + + assert defaults["id"] == "financial-control" + assert defaults["sections"] == [ + "executive-summary", + "kpi-overview", + "trend", + "detail-table", + "recommendations", + ] + assert defaults["components"] == [ + "KpiGrid", + "ChartBlock", + "DataTable", + "InsightBlock", + "RecommendationList", + ] + assert defaults["chart_policy"]["preferred"] == ["bar", "stacked-bar", "combo"] + assert defaults["table_policy"]["summary_rows"] is True + assert defaults["kpi_policy"]["include_variance"] is True + + +def _write_profile(root: Path, profile: dict[str, object]) -> None: + lines = ["---"] + for key, value in profile.items(): + lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") + lines.extend(["---", "", f"# {profile['label']}"]) + (root / f"{profile['id']}.md").write_text("\n".join(lines), encoding="utf-8") + + +def _base_profile(profile_id: str = "financial-control") -> dict[str, object]: + return { + "id": profile_id, + "label": "財務控制", + "best_for": ["費用分析"], + "required_sections": ["executive-summary"], + "default_components": ["DataTable"], + "chart_policy": {"preferred": ["bar"], "avoid": []}, + "table_policy": {"density": "compact", "summary_rows": True, "conditional_formatting": True}, + "kpi_policy": {"include_variance": True, "include_budget_ratio": True}, + "tone": "管理控制", + "layout_density": "dense", + "validator_focus": ["aggregate_consistency"], + } + + +def _write_complete_index(root: Path) -> None: + root.joinpath("index.json").write_text(json.dumps(REQUIRED_PROFILE_IDS), encoding="utf-8") + + +def _write_complete_profiles(root: Path, *, skip: set[str] | None = None) -> None: + skipped = skip or set() + for profile_id in REQUIRED_PROFILE_IDS: + if profile_id in skipped: + continue + profile = _base_profile(profile_id) + profile["label"] = profile_id + _write_profile(root, profile) + + +def test_catalog_rejects_missing_profile_file(tmp_path): + _write_complete_index(tmp_path) + _write_complete_profiles(tmp_path, skip={"financial-control"}) + + try: + load_report_design_catalog(tmp_path) + except ValueError as exc: + assert "Missing report design profile file" in str(exc) + assert "financial-control.md" in str(exc) + else: + raise AssertionError("Expected missing profile file to be rejected") + + +def test_catalog_rejects_profile_not_listed_in_index(tmp_path): + _write_complete_index(tmp_path) + _write_complete_profiles(tmp_path) + _write_profile(tmp_path, _base_profile("rogue-design")) + + try: + load_report_design_catalog(tmp_path) + except ValueError as exc: + assert "Profile file is not listed in index.json" in str(exc) + assert "rogue-design.md" in str(exc) + else: + raise AssertionError("Expected unindexed profile to be rejected") + + +def test_catalog_rejects_unindexed_filename_even_with_indexed_profile_id(tmp_path): + _write_complete_index(tmp_path) + _write_complete_profiles(tmp_path) + _write_profile(tmp_path, _base_profile("financial-control")) + (tmp_path / "financial-control.md").replace(tmp_path / "rogue.md") + _write_profile(tmp_path, _base_profile("financial-control")) + + try: + load_report_design_catalog(tmp_path) + except ValueError as exc: + assert "Profile file is not listed in index.json" in str(exc) + assert "rogue.md" in str(exc) + else: + raise AssertionError("Expected unindexed duplicate-id profile file to be rejected") + + +def test_catalog_rejects_profiles_missing_required_keys(tmp_path): + _write_complete_index(tmp_path) + _write_complete_profiles(tmp_path, skip={"financial-control"}) + profile = _base_profile("financial-control") + del profile["validator_focus"] + _write_profile(tmp_path, profile) + + try: + load_report_design_catalog(tmp_path) + except ValueError as exc: + assert "missing metadata" in str(exc) + assert "validator_focus" in str(exc) + else: + raise AssertionError("Expected missing required metadata to be rejected") diff --git a/tests/skill_scripts/test_report_gate_checks.py b/tests/skill_scripts/test_report_gate_checks.py new file mode 100644 index 0000000..ed8474a --- /dev/null +++ b/tests/skill_scripts/test_report_gate_checks.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from uuid import uuid4 + +from skill_scripts.report_gate_checks import evaluate_delivery_artifacts +from skill_scripts.report_gate_checks import load_json_no_bom +from skill_scripts.report_gate_checks import scan_run_text_artifacts +from skill_scripts.report_gate_checks import scan_text_readability + + +def _case_dir(name: str) -> Path: + path = Path("tests") / ".tmp" / f"{name}-{uuid4().hex}" + path.mkdir(parents=True, exist_ok=True) + return path + + +def test_readability_scan_rejects_repeated_question_marks_and_mojibake() -> None: + result = scan_text_readability("報表標題 ???? 閮") + + assert result["valid"] is False + assert "repeated_question_marks" in result["errors"] + assert "mojibake_marker" in result["errors"] + + +def test_run_text_artifact_scan_covers_checkpoint_plan_data_and_state_json() -> None: + run_dir = _case_dir("run-readability") / "run" + for relative_path, text in { + "checkpoints/01b_field_formula_classification.json": '{"label": "????"}', + "plan/source-to-output-matrix.json": '[{"column": "Excel??"}]', + "data/excel-workbook-preview.json": '{"status": "ok"}', + "state.json": '{"status": "ok"}', + }.items(): + path = run_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + result = scan_run_text_artifacts(run_dir) + + assert result["valid"] is False + assert "checkpoints/01b_field_formula_classification.json:repeated_question_marks" in result["errors"] + assert "plan/source-to-output-matrix.json:repeated_question_marks" in result["errors"] + + +def test_cli_check_run_text_artifacts_blocks_unreadable_run_payload() -> None: + run_dir = _case_dir("cli-run-readability") / "run" + checkpoint_path = run_dir / "checkpoints" / "01b_field_formula_classification.json" + checkpoint_path.parent.mkdir(parents=True, exist_ok=True) + checkpoint_path.write_text('{"label": "????"}', encoding="utf-8") + + completed = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "check-run-text-artifacts", + "--run-dir", + str(run_dir), + ], + capture_output=True, + check=False, + encoding="utf-8", + ) + + assert completed.returncode == 2 + assert "repeated_question_marks" in completed.stdout + + +def test_load_json_no_bom_rejects_utf8_bom() -> None: + path = _case_dir("bom") / "validator.json" + path.write_bytes(b"\xef\xbb\xbf" + json.dumps({"status": "pass"}).encode("utf-8")) + + result = load_json_no_bom(path) + + assert result["valid"] is False + assert result["error"] == "utf8_bom" + + +def test_delivery_artifact_gate_blocks_hash_mismatch_and_missing_validator() -> None: + run_dir = _case_dir("blocked") / "run" + delivery_dir = run_dir / "report" / "delivery" + validators_dir = run_dir / "review" / "validators" + delivery_dir.mkdir(parents=True) + validators_dir.mkdir(parents=True) + html_path = delivery_dir / "report.html" + html_path.write_text( + "訂單專案即時分析管理報表" + "" + "", + encoding="utf-8", + ) + (delivery_dir / "manifest.json").write_text( + json.dumps( + { + "html_path": "report/delivery/report.html", + "sha256": "not-the-real-hash", + "single_file": True, + "network_dependencies": 0, + } + ), + encoding="utf-8", + ) + (validators_dir / "report-content.json").write_text( + json.dumps( + { + "role": "report_content_reviewer", + "status": "pass", + "required_fixes": [], + } + ), + encoding="utf-8", + ) + + result = evaluate_delivery_artifacts( + run_dir, + required_validators=["report_content_reviewer", "visual_taste_reviewer"], + ) + + assert result["allowed"] is False + assert "manifest_hash_mismatch" in result["blocking_reasons"] + assert "missing_validator:visual_taste_reviewer" in result["blocking_reasons"] + + +def test_delivery_artifact_gate_allows_complete_delivery() -> None: + run_dir = _case_dir("complete") / "run" + delivery_dir = run_dir / "report" / "delivery" + validators_dir = run_dir / "review" / "validators" + delivery_dir.mkdir(parents=True) + validators_dir.mkdir(parents=True) + html_path = delivery_dir / "report.html" + html_text = ( + "訂單專案即時分析管理報表 A15-003 150331006 " + "" + "" + ) + html_path.write_text(html_text, encoding="utf-8") + (delivery_dir / "manifest.json").write_text( + json.dumps( + { + "html_path": "report/delivery/report.html", + "sha256": hashlib.sha256(html_text.encode("utf-8")).hexdigest(), + "single_file": True, + "network_dependencies": 0, + } + ), + encoding="utf-8", + ) + for role in ("report_content_reviewer", "visual_taste_reviewer"): + (validators_dir / f"{role}.json").write_text( + json.dumps({"role": role, "status": "warning", "required_fixes": []}), + encoding="utf-8", + ) + + result = evaluate_delivery_artifacts( + run_dir, + required_validators=["report_content_reviewer", "visual_taste_reviewer"], + ) + + assert result["allowed"] is True + assert result["blocking_reasons"] == [] + + +def test_cli_check_delivery_artifacts_returns_success_for_complete_delivery() -> None: + run_dir = _case_dir("cli-complete") / "run" + delivery_dir = run_dir / "report" / "delivery" + validators_dir = run_dir / "review" / "validators" + delivery_dir.mkdir(parents=True) + validators_dir.mkdir(parents=True) + html_path = delivery_dir / "report.html" + html_text = ( + "訂單專案即時分析管理報表 A15-003 150331006 " + "" + "" + ) + html_path.write_text(html_text, encoding="utf-8") + (delivery_dir / "manifest.json").write_text( + json.dumps( + { + "html_path": "report/delivery/report.html", + "sha256": hashlib.sha256(html_text.encode("utf-8")).hexdigest(), + "single_file": True, + "network_dependencies": 0, + } + ), + encoding="utf-8", + ) + (validators_dir / "report-content.json").write_text( + json.dumps( + { + "role": "report_content_reviewer", + "status": "pass", + "required_fixes": [], + } + ), + encoding="utf-8", + ) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "check-delivery-artifacts", + "--run-dir", + str(run_dir), + "--required-validator", + "report_content_reviewer", + ], + capture_output=True, + check=False, + encoding="utf-8", + ) + + assert completed.returncode == 0, completed.stderr + assert '"allowed": true' in completed.stdout diff --git a/tests/skill_scripts/test_report_harness.py b/tests/skill_scripts/test_report_harness.py new file mode 100644 index 0000000..23c03a7 --- /dev/null +++ b/tests/skill_scripts/test_report_harness.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief +from skill_scripts.report_harness import ReportHarness, ReportHarnessError +from skill_scripts.report_harness_state import CHECKPOINT_DEFINITIONS, write_confirmation +from skill_scripts.validator_contracts import REQUIRED_VALIDATORS +from skill_scripts.visual_checkpoint import build_visual_checkpoint_payload + + +def _validator_result(role: str, status: str = "pass") -> dict[str, object]: + evidence: list[dict[str, object]] = [{"command": f"review {role}"}] + if role == "data_preview_reviewer": + evidence = [ + { + "name": "preview_shape", + "status": "pass", + "metrics": {"row_count": 10, "column_count": 4}, + } + ] + if role == "excel_classification_reviewer": + evidence = [ + {"type": "file", "path": "data/field-classification.json"}, + {"type": "metric", "name": "classified_columns", "value": 10}, + {"type": "metric", "name": "db_field_count", "value": 6}, + {"type": "metric", "name": "formula_field_count", "value": 2}, + {"type": "metric", "name": "lookup_field_count", "value": 1}, + {"type": "metric", "name": "manual_only_count", "value": 1}, + {"type": "inspection", "name": "metadata_readability", "status": "pass"}, + ] + if role == "sqlite_enrichment_reviewer": + evidence = [ + {"type": "file", "path": "sqlite/wferp_run_sqlite_manifest.json"}, + {"type": "metric", "name": "raw_row_count", "value": 10}, + {"type": "metric", "name": "enriched_row_count", "value": 10}, + {"type": "metric", "name": "ignored_lookup_rows", "value": 0}, + ] + return { + "role": role, + "status": status, + "reviewer_identity": {"kind": "subagent", "id": f"{role}-agent"}, + "checked_scope": ["run-dir"], + "input_artifact_paths": ["checkpoints/current.json"], + "reviewed_at": "2026-06-20T00:00:00Z", + "evidence": evidence, + "findings": [] if status == "pass" else [f"{role} failed"], + "requiredFixes": [] if status == "pass" else [f"repair {role}"], + "residualRisks": [] if status == "pass" else [f"accepted risk for {role}"], + } + + +def _all_validator_results(status_overrides: dict[str, str] | None = None) -> list[dict[str, object]]: + overrides = status_overrides or {} + return [_validator_result(role, overrides.get(role, "pass")) for role in REQUIRED_VALIDATORS] + + +def _sql_gate_validator_results() -> list[dict[str, object]]: + return [ + _validator_result("sql_safety_reviewer"), + _validator_result("schema_mapping_reviewer"), + ] + + +def _valid_design_brief(harness: ReportHarness) -> dict[str, object]: + brief = build_design_brief( + { + "catalog_guardrail": "financial-control", + "prompt": harness.state().get("prompt"), + "report_type": harness.state().get("report_type"), + "data_profile": {"columns": ["department", "amount"], "row_count": 1}, + "datasets": {"columns": ["department", "amount"]}, + } + ) + assert validate_design_brief(brief)["valid"] is True + return brief + + +def _confirm_design_brief(harness: ReportHarness) -> None: + brief = _valid_design_brief(harness) + harness.write_design_brief(brief) + harness.confirm("design_brief", "確認設計") + + +def _confirm_visual_design(harness: ReportHarness) -> None: + package = { + "catalog_guardrail": "financial-control", + "prompt": harness.state().get("prompt"), + "report_type": harness.state().get("report_type"), + "data_profile": {"columns": ["department", "amount"], "row_count": 2}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [ + {"department": "管理部", "amount": 1000}, + {"department": "研發部", "amount": 2500}, + ], + }, + "aggregates": {"amount_sum": 3500, "amount_avg": 1750}, + } + payload = build_visual_checkpoint_payload(harness.state()["report_design_brief"], package) + harness.write_visual_design(payload) + harness.confirm("visual_design", "確認視覺設計") + + +def test_rejects_state_transition_without_required_confirmation(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT * FROM ACPTA") + + with pytest.raises(ReportHarnessError, match="SQL must be confirmed"): + harness.write_data_preview({"rows": [], "row_count": 0}) + + +def test_field_formula_readability_failure_does_not_pollute_state(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="prompt") + + with pytest.raises(ReportHarnessError, match="text readability"): + harness.write_field_formula_classification( + {"source_to_output_matrix": [{"Excel??": "A", "label": "????"}]} + ) + + state = harness.state() + assert state["column_classification"] is None + assert not ( + tmp_path / "demo-run" / "checkpoints" / "01b_field_formula_classification.json" + ).exists() + + +def test_confirmed_sql_allows_data_preview_checkpoint(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT * FROM ACPTA") + harness.update_state(validator_results=_sql_gate_validator_results()) + harness.confirm("sql_review", "同意查詢") + + checkpoint = harness.write_data_preview({"rows": [{"部門": "D001"}], "row_count": 1}) + + assert harness.state()["user_confirmations"]["sql_review"] == "同意查詢" + assert checkpoint["checkpoint"] == "data_preview" + assert checkpoint["payload"]["row_count"] == 1 + + +def test_state_gate_blocks_data_preview_when_confirmation_identity_is_missing(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT * FROM ACPTA") + harness.update_state( + artifact_status={ + "sql/query.sql": "complete", + "checkpoints/02_sql_review.json": "complete", + }, + validator_results=_sql_gate_validator_results(), + user_confirmations={"sql_review": "同意查詢"}, + ) + + with pytest.raises(ReportHarnessError, match="confirmation_identity"): + harness.write_data_preview({"rows": [{"部門": "D001"}], "row_count": 1}) + + +def test_rewriting_sql_review_invalidates_confirmation_identity(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT 1") + harness.update_state(validator_results=_sql_gate_validator_results()) + harness.confirm("sql_review", "同意查詢") + old_identity = harness.state()["confirmation_identity"]["sql_review"] + + harness.write_sql_review("SELECT 2") + harness.update_state( + validator_results=_sql_gate_validator_results(), + user_confirmations={"sql_review": "同意查詢"}, + confirmation_identity={"sql_review": old_identity}, + ) + + with pytest.raises(ReportHarnessError, match="confirmation_identity"): + harness.write_data_preview({"rows": [{"部門": "D001"}], "row_count": 1}) + + +def test_rewriting_sql_review_requires_reconfirmation(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT 1") + harness.update_state(validator_results=_sql_gate_validator_results()) + harness.confirm("sql_review", "同意查詢") + harness.write_sql_review("SELECT 2") + + with pytest.raises(ReportHarnessError, match="SQL must be confirmed"): + harness.write_data_preview({"rows": [], "row_count": 0}) + + +def test_rewriting_sql_review_clears_downstream_state_and_checkpoints(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_sql_review("SELECT 1") + harness.update_state(validator_results=_sql_gate_validator_results()) + harness.confirm("sql_review", "同意查詢") + harness.write_data_preview({"rows": [{"部門": "D001"}], "row_count": 1}) + harness.write_report_selection( + { + "selected_report_type": "管理摘要", + "selected_report_design": "financial-control", + "selected_options": {"include_table": True}, + } + ) + + harness.write_sql_review("SELECT 2") + + state = harness.state() + assert state["execution_result_summary"] is None + assert state["report_type"] is None + assert state["report_design"] is None + assert state["report_options"] == {} + + +def test_prompt_repair_blocks_forward_progress_until_cleared(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="build report") + harness.write_sql_review("SELECT * FROM ACPTA", {"status": "pass"}) + repair_action = CHECKPOINT_DEFINITIONS["sql_review"]["actions"][1] + + harness.confirm( + "sql_review", + repair_action, + selected_options={ + "changeScope": "sql_conditions", + "targetUserStep": 2, + "requiresRerender": True, + "prompt": "add date condition", + }, + comment="add date condition", + ) + + state = harness.state() + assert state["blocking_repair_request"]["changeScope"] == "sql_conditions" + assert state["blocking_repair_request"]["comment"] == "add date condition" + assert state["allowed_next_actions"] == ["repair_current_step"] + assert "execute_select" not in state["allowed_next_actions"] + assert [checkpoint["checkpoint"] for checkpoint in state["checkpoints"]] == ["sql_review"] + assert not (harness.run_dir / "checkpoints" / "03_data_preview.json").exists() + assert not (harness.run_dir / "checkpoints" / "04_report_selection.json").exists() + + +def test_confirm_requires_existing_checkpoint_and_valid_action(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + + with pytest.raises(ReportHarnessError, match="checkpoint has not been created"): + harness.confirm("sql_review", "同意查詢") + + harness.write_sql_review("SELECT * FROM ACPTA") + + with pytest.raises(ReportHarnessError, match="Invalid checkpoint action"): + harness.confirm("sql_review", "直接執行") + + +def test_report_draft_requires_report_selection_confirmation(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_report_selection({"report_types": ["管理摘要"]}) + + with pytest.raises(ReportHarnessError, match="Report selection must be confirmed"): + harness.write_report_draft({"sections": []}) + + +def test_report_selection_updates_canonical_state_fields(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + + harness.write_report_selection( + { + "selected_report_type": "管理摘要", + "selected_report_design": "financial-control", + "selected_options": { + "include_chart": True, + "include_table": True, + "include_analysis": True, + "include_recommendations": False, + }, + } + ) + + state = harness.state() + assert state["report_type"] == "管理摘要" + assert state["report_design"] == "financial-control" + assert state["report_options"]["include_chart"] is True + + +def test_final_review_requires_draft_acceptance(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_report_selection({"report_types": ["管理摘要"]}) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + + with pytest.raises(ReportHarnessError, match="Draft must be accepted"): + harness.write_final_review({"status": "ready"}) + + +def test_success_is_blocked_when_any_validator_fails(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results({"visual_taste_reviewer": "fail"}), + } + ) + + assert harness.can_deliver() == { + "allowed": False, + "blocking_validators": ["visual_taste_reviewer"], + "accepted_residual_risks": [], + } + + +def test_delivery_allows_explicitly_accepted_residual_risk_at_final_checkpoint(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results({"visual_taste_reviewer": "warning"}), + } + ) + + assert harness.can_deliver()["allowed"] is False + + harness.confirm( + "final_review", + "完成", + selected_options={ + "acceptedResidualRisks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + }, + ) + + assert harness.can_deliver() == { + "allowed": True, + "blocking_validators": [], + "accepted_residual_risks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + } + + +def test_final_review_payload_string_residual_risks_do_not_allow_delivery(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results({"visual_taste_reviewer": "warning"}), + "accepted_residual_risks": "yes", + } + ) + harness.confirm("final_review", "完成") + + assert harness.can_deliver() == { + "allowed": False, + "blocking_validators": ["visual_taste_reviewer"], + "accepted_residual_risks": [], + } + + +def test_non_pass_validator_delivers_only_with_matching_user_accepted_risk(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results({"visual_taste_reviewer": "warning"}), + } + ) + + harness.confirm( + "final_review", + "完成", + selected_options={ + "acceptedResidualRisks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + }, + ) + + assert harness.can_deliver() == { + "allowed": True, + "blocking_validators": [], + "accepted_residual_risks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + } + + +def test_partial_residual_risk_acceptance_still_blocks_other_non_pass_validators(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results( + { + "visual_taste_reviewer": "warning", + "data_visualization_reviewer": "warning", + } + ), + } + ) + + harness.confirm( + "final_review", + "完成", + selected_options={ + "acceptedResidualRisks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + }, + ) + + assert harness.can_deliver() == { + "allowed": False, + "blocking_validators": ["data_visualization_reviewer"], + "accepted_residual_risks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + } + + +def test_can_deliver_reads_final_confirmation_file_when_state_options_are_empty(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review( + { + "validator_results": _all_validator_results({"visual_taste_reviewer": "warning"}), + } + ) + harness.confirm("final_review", "完成") + checkpoint_payload = json.loads( + (harness.run_dir / "checkpoints" / "06_final_review.json").read_text(encoding="utf-8") + ) + write_confirmation( + harness.run_dir, + "final_review", + { + "action": "完成", + "run_id": checkpoint_payload["run_id"], + "checkpoint_id": checkpoint_payload["checkpoint_id"], + "payload_hash": checkpoint_payload["payload_hash"], + "confirmation_id": "confirm-final-risk", + "selectedOptions": { + "acceptedResidualRisks": [ + "visual_taste_reviewer: accepted risk for visual_taste_reviewer" + ] + }, + }, + ) + + assert harness.state()["user_confirmation_options"]["final_review"] == {} + assert harness.can_deliver() == { + "allowed": True, + "blocking_validators": [], + "accepted_residual_risks": ["visual_taste_reviewer: accepted risk for visual_taste_reviewer"], + } + + +def test_append_repair_log_writes_minimal_vertical_slice_entry(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + + path = harness.append_repair_log( + validator="sql_safety_reviewer", + failure="SELECT INTO is blocked", + scope="sql/query.sql", + minimal_vertical_slice="Remove SELECT INTO while preserving selected columns", + files_changed=["sql/query.sql", "skill_scripts/report_harness.py"], + validation_rerun="pytest tests/skill_scripts/test_report_harness.py -v", + residual_risk="None", + ) + + content = path.read_text(encoding="utf-8") + assert "sql_safety_reviewer" in content + assert "Failure:\nSELECT INTO is blocked" in content + assert "Scope:\nsql/query.sql" in content + assert "Minimal vertical slice:\nRemove SELECT INTO while preserving selected columns" in content + assert "Files changed:\n- sql/query.sql\n- skill_scripts/report_harness.py" in content + assert "Validation rerun:\npytest tests/skill_scripts/test_report_harness.py -v" in content + assert "Residual risk:\nNone" in content + + +def test_write_design_brief_records_checkpoint_and_clears_downstream_state(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.update_state( + validator_results=[_validator_result("visual_taste_reviewer")], + visual_design_checkpoint={"status": "ready"}, + ) + + brief = _valid_design_brief(harness) + brief["chart_recipe"][0]["purpose"] = "比較部門" + assert validate_design_brief(brief)["valid"] is True + + checkpoint = harness.write_design_brief(brief) + + state = harness.state() + assert checkpoint["checkpoint"] == "design_brief" + assert checkpoint["title"] == "動態設計確認" + assert state["report_design_brief"] == brief + assert state["visual_design_checkpoint"] is None + assert state["validator_results"] == [] + assert "design_brief" not in state["user_confirmations"] + assert "report_draft" not in {item["checkpoint"] for item in state["checkpoints"]} + assert not (harness.run_dir / "checkpoints" / "05_report_draft.json").exists() + + +def test_write_visual_design_requires_confirmed_design_brief(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + payload = {"title": "費用分析視覺設計確認"} + + with pytest.raises( + ReportHarnessError, + match="Design brief must be confirmed before visual checkpoint", + ): + harness.write_visual_design(payload) + + +def test_write_visual_design_records_checkpoint_and_clears_downstream_state(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["摘要"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review({"validator_results": _all_validator_results()}) + + payload = {"title": "費用分析視覺設計確認", "kpis": [{"label": "amount_sum", "value": 3500}]} + checkpoint = harness.write_visual_design(payload) + + state = harness.state() + assert checkpoint["checkpoint"] == "visual_design" + assert checkpoint["title"] == "視覺設計確認" + assert state["visual_design_checkpoint"] == payload + assert state["validator_results"] == [] + assert "visual_design" not in state["user_confirmations"] + assert "report_draft" not in {item["checkpoint"] for item in state["checkpoints"]} + assert "final_review" not in {item["checkpoint"] for item in state["checkpoints"]} + assert not (harness.run_dir / "checkpoints" / "05_report_draft.json").exists() + assert not (harness.run_dir / "checkpoints" / "06_final_review.json").exists() + + +def test_report_draft_requires_confirmed_design_brief_after_report_selection(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="demo-run", prompt="請查詢費用") + harness.write_report_selection({"report_types": ["管理摘要"]}) + harness.confirm("report_selection", "產生報告") + harness.write_design_brief(_valid_design_brief(harness)) + + with pytest.raises(ReportHarnessError, match="Design brief must be confirmed before writing draft"): + harness.write_report_draft({"sections": []}) + + +def test_changed_report_selection_clears_design_brief_and_future_checkpoints(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="查詢費用") + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + harness.update_state( + visual_design_checkpoint={"status": "ready"}, + validator_results=[_validator_result("visual_taste_reviewer")], + ) + + harness.write_report_selection( + {"selected_report_type": "明細表", "selected_report_design": "financial-control"} + ) + + state = harness.state() + assert state["report_design_brief"] is None + assert state["visual_design_checkpoint"] is None + assert state["validator_results"] == [] + assert "design_brief" not in state["user_confirmations"] + assert "design_brief" not in {item["checkpoint"] for item in state["checkpoints"]} + assert not (harness.run_dir / "checkpoints" / "04a_design_brief.json").exists() diff --git a/tests/skill_scripts/test_report_harness_state.py b/tests/skill_scripts/test_report_harness_state.py new file mode 100644 index 0000000..a80513f --- /dev/null +++ b/tests/skill_scripts/test_report_harness_state.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from skill_scripts.report_harness_state import ( + CHECKPOINT_DEFINITIONS, + append_audit_event, + create_report_run, + load_run_state, + record_checkpoint, + write_confirmation, +) + + +def test_creates_run_directory_with_state_json(tmp_path: Path): + state = create_report_run( + tmp_path, + run_id="demo-run", + prompt="請產出費用分析", + input_files=["需求.xlsx"], + ) + + run_dir = tmp_path / "demo-run" + assert run_dir.is_dir() + assert (run_dir / "state.json").is_file() + for child in [ + "inputs", + "sql", + "data", + "checkpoints", + "reports", + "source", + "plan", + "audit", + "review", + "report/payload", + ]: + assert (run_dir / child).is_dir() + + persisted = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert persisted["run_id"] == "demo-run" + assert persisted["prompt"] == "請產出費用分析" + assert persisted["input_files"] == ["需求.xlsx"] + assert state["user_confirmations"] == {} + assert persisted["current_user_step"] == 1 + assert persisted["current_internal_phase"] == 0 + assert persisted["delivery_status"] == "not_ready" + assert persisted["allowed_next_actions"] == ["prepare_source_logic"] + + +def test_create_report_run_rejects_existing_run_id(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="first") + + with pytest.raises(FileExistsError, match="demo-run"): + create_report_run(tmp_path, run_id="demo-run", prompt="second") + + +def test_records_excel_confirmation_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "excel_confirmation", + {"資料庫欄位": [{"顯示名稱": "未稅金額"}]}, + ) + + path = tmp_path / "demo-run" / "checkpoints" / "01_excel_confirmation.json" + assert path.is_file() + assert checkpoint["title"] == "確認欄位與公式" + assert checkpoint["actions"] == ["確認欄位與公式", "要求修正"] + + +def test_records_sql_review_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "sql_review", + {"sql": "SELECT * FROM ACPTA"}, + ) + + assert checkpoint["title"] == "SQL 查詢確認" + assert checkpoint["actions"] == ["同意查詢", "調整需求"] + + +def test_checkpoint_records_payload_hash_and_checkpoint_id(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 1"}) + persisted = json.loads( + (tmp_path / "demo-run" / "checkpoints" / "02_sql_review.json").read_text(encoding="utf-8") + ) + + assert checkpoint["run_id"] == "demo-run" + assert checkpoint["checkpoint"] == "sql_review" + assert checkpoint["checkpoint_id"] == "sql_review" + assert len(checkpoint["payload_hash"]) == 64 + assert persisted["payload_hash"] == checkpoint["payload_hash"] + state = load_run_state(tmp_path / "demo-run") + assert state["gate_status"]["phase_4_sql_review"]["confirmation"]["payload_hash"] == checkpoint["payload_hash"] + + +def test_record_checkpoint_rejects_replacement_question_mark_payload(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + with pytest.raises(ValueError, match="text readability"): + record_checkpoint( + tmp_path / "demo-run", + "field_formula_classification", + {"source_to_output_matrix": [{"Excel??": "A", "label": "????"}]}, + ) + + assert not ( + tmp_path / "demo-run" / "checkpoints" / "01b_field_formula_classification.json" + ).exists() + + +def test_write_confirmation_requires_matching_identity(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + checkpoint = record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 1"}) + + confirmation = write_confirmation( + tmp_path / "demo-run", + "sql_review", + { + "action": "同意查詢", + "run_id": "demo-run", + "checkpoint_id": "sql_review", + "payload_hash": checkpoint["payload_hash"], + "confirmation_id": "confirm-001", + }, + ) + + assert confirmation["run_id"] == "demo-run" + assert confirmation["checkpoint_id"] == "sql_review" + assert confirmation["payload_hash"] == checkpoint["payload_hash"] + assert confirmation["confirmation_id"] == "confirm-001" + state = load_run_state(tmp_path / "demo-run") + assert state["confirmation_identity"]["sql_review"]["confirmation_id"] == "confirm-001" + + +def test_write_confirmation_rejects_stale_payload_hash(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 1"}) + + with pytest.raises(ValueError, match="confirmation identity"): + write_confirmation( + tmp_path / "demo-run", + "sql_review", + { + "action": "同意查詢", + "run_id": "demo-run", + "checkpoint_id": "sql_review", + "payload_hash": "0" * 64, + "confirmation_id": "confirm-stale", + }, + ) + + +def test_records_data_preview_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "data_preview", + {"row_count": 3, "columns": ["部門", "總額"]}, + ) + + assert checkpoint["title"] == "資料預覽確認" + assert checkpoint["actions"] == ["資料正確", "確認資料", "重新查詢"] + + +def test_records_report_selection_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "report_selection", + {"report_types": ["管理摘要"]}, + ) + + assert checkpoint["title"] == "報表格式選擇" + assert checkpoint["actions"] == ["產生報告", "修改格式"] + + +def test_records_design_brief_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "design_brief", + {"schema_version": "wferp.design-brief.v1"}, + ) + + assert checkpoint["title"] == "動態設計確認" + assert checkpoint["actions"] == ["確認設計", "調整設計"] + assert CHECKPOINT_DEFINITIONS["design_brief"]["index"] == 4.1 + assert CHECKPOINT_DEFINITIONS["design_brief"]["file"] == "04a_design_brief.json" + assert (tmp_path / "demo-run" / "checkpoints" / "04a_design_brief.json").is_file() + + +def test_records_visual_design_checkpoint(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + checkpoint = record_checkpoint( + tmp_path / "demo-run", + "visual_design", + {"title": "費用分析視覺設計確認"}, + ) + + assert checkpoint["title"] == "視覺設計確認" + assert checkpoint["actions"] == ["確認視覺設計", "調整視覺設計"] + assert CHECKPOINT_DEFINITIONS["visual_design"]["index"] == 4.2 + assert CHECKPOINT_DEFINITIONS["visual_design"]["file"] == "04b_visual_design.json" + assert (tmp_path / "demo-run" / "checkpoints" / "04b_visual_design.json").is_file() + + +def test_all_checkpoint_payloads_have_chinese_titles_and_actions(): + expected_actions = { + "excel_confirmation": ["確認欄位與公式", "要求修正"], + "field_formula_classification": ["確認欄位分類", "要求修正"], + "sql_review": ["同意查詢", "調整需求"], + "data_preview": ["資料正確", "確認資料", "重新查詢"], + "raw_data_preview": ["原始資料正確", "重新查詢"], + "enriched_data_preview": ["補欄資料正確", "修正補欄規則"], + "report_selection": ["產生報告", "修改格式"], + "design_brief": ["確認設計", "調整設計"], + "visual_design": ["確認視覺設計", "調整視覺設計"], + "report_draft": ["接受", "修正報告"], + "final_review": ["完成", "回到初稿"], + "sqlite_retention": ["保留本地資料", "刪除本次資料", "匯出封存後刪除"], + } + + assert set(CHECKPOINT_DEFINITIONS) == set(expected_actions) + for key, actions in expected_actions.items(): + definition = CHECKPOINT_DEFINITIONS[key] + assert definition["title"] + assert definition["actions"] == actions + assert all(any("\u4e00" <= character <= "\u9fff" for character in action) for action in actions) + + +def test_load_run_state_round_trips_persisted_state(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + record_checkpoint(tmp_path / "demo-run", "excel_confirmation", {"ok": True}) + + state = load_run_state(tmp_path / "demo-run") + + assert state["run_id"] == "demo-run" + assert state["checkpoints"][-1]["checkpoint"] == "excel_confirmation" + + +def test_repeated_checkpoint_replaces_history_entry(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 1"}) + record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 2"}) + + state = load_run_state(tmp_path / "demo-run") + + assert [item["checkpoint"] for item in state["checkpoints"]] == ["sql_review"] + checkpoint = json.loads( + (tmp_path / "demo-run" / "checkpoints" / "02_sql_review.json").read_text(encoding="utf-8") + ) + assert checkpoint["payload"]["sql"] == "SELECT 2" + + +def test_write_confirmation_uses_checkpoint_filename_and_preserves_payload(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + checkpoint = record_checkpoint(tmp_path / "demo-run", "sql_review", {"sql": "SELECT 1"}) + + confirmation = write_confirmation( + tmp_path / "demo-run", + "sql_review", + { + "action": "同意查詢", + "comment": "條件正確,可以查詢", + "selectedOptions": {"view": "management"}, + "run_id": "demo-run", + "checkpoint_id": "sql_review", + "payload_hash": checkpoint["payload_hash"], + "confirmation_id": "confirm-002", + }, + ) + + path = tmp_path / "demo-run" / "checkpoints" / "02_sql_review.confirmation.json" + persisted = json.loads(path.read_text(encoding="utf-8")) + assert persisted == confirmation + assert persisted["checkpoint"] == "sql_review" + assert persisted["action"] == "同意查詢" + assert persisted["comment"] == "條件正確,可以查詢" + assert persisted["selectedOptions"] == {"view": "management"} + assert persisted["payload_hash"] == checkpoint["payload_hash"] + assert persisted["confirmation_id"] == "confirm-002" + + +def test_append_audit_event_writes_jsonl(tmp_path: Path): + create_report_run(tmp_path, run_id="demo-run", prompt="prompt") + + entry = append_audit_event( + tmp_path / "demo-run", + "checkpoint_confirmed", + {"checkpoint": "sql_review", "action": "同意查詢"}, + ) + + lines = (tmp_path / "demo-run" / "audit" / "events.jsonl").read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0]) == entry + assert entry["event"] == "checkpoint_confirmed" + assert entry["payload"]["checkpoint"] == "sql_review" diff --git a/tests/skill_scripts/test_report_package.py b/tests/skill_scripts/test_report_package.py new file mode 100644 index 0000000..230521a --- /dev/null +++ b/tests/skill_scripts/test_report_package.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from pathlib import Path + +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief +from skill_scripts.report_harness import ReportHarness +from skill_scripts.report_package import build_report_package, validate_report_package +from skill_scripts.validator_contracts import REQUIRED_VALIDATORS +from skill_scripts.visual_checkpoint import build_visual_checkpoint_payload + + +def _validator_result(role: str, status: str = "pass") -> dict[str, object]: + evidence: list[dict[str, object]] = [{"command": f"review {role}"}] + if role == "data_preview_reviewer": + evidence = [ + { + "name": "preview_shape", + "status": "pass", + "metrics": {"row_count": 2, "column_count": 2}, + } + ] + if role == "excel_classification_reviewer": + evidence = [ + {"type": "file", "path": "data/field-classification.json"}, + {"type": "metric", "name": "classified_columns", "value": 2}, + {"type": "metric", "name": "db_field_count", "value": 1}, + {"type": "metric", "name": "formula_field_count", "value": 1}, + {"type": "metric", "name": "lookup_field_count", "value": 0}, + {"type": "metric", "name": "manual_only_count", "value": 0}, + {"type": "inspection", "name": "metadata_readability", "status": "pass"}, + ] + if role == "sqlite_enrichment_reviewer": + evidence = [ + {"type": "file", "path": "sqlite/wferp_run_sqlite_manifest.json"}, + {"type": "metric", "name": "raw_row_count", "value": 2}, + {"type": "metric", "name": "enriched_row_count", "value": 2}, + {"type": "metric", "name": "ignored_lookup_rows", "value": 0}, + ] + return { + "role": role, + "status": status, + "reviewer_identity": {"kind": "subagent", "id": f"{role}-agent"}, + "checked_scope": ["run-dir"], + "input_artifact_paths": ["checkpoints/current.json"], + "reviewed_at": "2026-06-20T00:00:00Z", + "evidence": evidence, + "findings": [] if status == "pass" else [f"{role} failed"], + "requiredFixes": [] if status == "pass" else [f"repair {role}"], + "residualRisks": [] if status == "pass" else [f"accepted risk for {role}"], + } + + +def _all_validator_results() -> list[dict[str, object]]: + return [_validator_result(role) for role in REQUIRED_VALIDATORS] + + +def _sql_gate_validator_results() -> list[dict[str, object]]: + return [ + _validator_result("sql_safety_reviewer"), + _validator_result("schema_mapping_reviewer"), + ] + + +def _confirm_design_brief(harness: ReportHarness) -> None: + brief = build_design_brief( + { + "catalog_guardrail": "financial-control", + "prompt": harness.state().get("prompt"), + "report_type": harness.state().get("report_type"), + "data_profile": {"columns": ["department", "amount"], "row_count": 2}, + "datasets": {"columns": ["department", "amount"]}, + } + ) + assert validate_design_brief(brief)["valid"] is True + harness.write_design_brief(brief) + harness.confirm("design_brief", "確認設計") + + +def _confirm_visual_design(harness: ReportHarness) -> None: + package = { + "catalog_guardrail": "financial-control", + "prompt": harness.state().get("prompt"), + "report_type": harness.state().get("report_type"), + "data_profile": {"columns": ["department", "amount"], "row_count": 2}, + "datasets": { + "columns": ["department", "amount"], + "embedded_rows": [ + {"department": "管理部", "amount": 1000}, + {"department": "研發部", "amount": 2500}, + ], + }, + "aggregates": {"amount_sum": 3500, "amount_avg": 1750}, + } + payload = build_visual_checkpoint_payload(harness.state()["report_design_brief"], package) + harness.write_visual_design(payload) + harness.confirm("visual_design", "確認視覺設計") + + +def _accepted_report_run(tmp_path: Path) -> ReportHarness: + harness = ReportHarness.create( + tmp_path, + run_id="run-001", + prompt="查詢 2026 年費用明細", + ) + harness.write_sql_review( + "SELECT department, amount FROM expenses", + {"readonly": True, "status": "pass"}, + ) + harness.update_state(validator_results=_sql_gate_validator_results()) + harness.confirm("sql_review", "同意查詢") + harness.write_data_preview( + { + "rows": [ + {"department": "管理部", "amount": 1000}, + {"department": "研發部", "amount": 2500}, + ], + "columns": ["department", "amount"], + "row_count": 2, + "aggregates": {"amount_sum": 3500}, + "excluded_rows": [], + } + ) + harness.confirm("data_preview", "確認資料") + harness.write_report_selection( + { + "selected_report_type": "管理摘要", + "selected_report_design": "financial-control", + "selected_options": {"include_table": True, "include_chart": False}, + } + ) + harness.confirm("report_selection", "產生報告") + _confirm_design_brief(harness) + _confirm_visual_design(harness) + harness.write_report_draft({"sections": ["executive-summary", "data-table"]}) + harness.confirm("report_draft", "接受") + harness.write_final_review({"validator_results": _all_validator_results()}) + harness.confirm("final_review", "完成") + return harness + + +def test_build_report_package_uses_confirmed_run_state(tmp_path: Path): + harness = _accepted_report_run(tmp_path) + + package = build_report_package(harness.run_dir) + + assert package["schema_version"] == "wferp.report-package.v1" + assert package["run_id"] == "run-001" + assert package["package_id"] == "run-001" + assert package["catalog_guardrail"] == "financial-control" + assert package["report_type"] == "管理摘要" + assert package["report_options"] == {"include_table": True, "include_chart": False} + assert package["sql"]["text"] == "SELECT department, amount FROM expenses" + assert package["data_profile"]["row_count"] == 2 + assert package["data_profile"]["columns"] == ["department", "amount"] + assert package["data_profile"]["embedded_mode"] == "full_rows" + assert package["data_profile"]["embedded_rows"] == 2 + assert package["data_profile"]["full_rows_in_evidence_packet"] is False + assert package["datasets"]["embedded_rows"] == [ + {"department": "管理部", "amount": 1000}, + {"department": "研發部", "amount": 2500}, + ] + assert package["aggregates"] == {"amount_sum": 3500} + assert package["evidence_index"] == [ + {"id": "sql", "path": "evidence/query.sql"}, + {"id": "execution_result", "path": "evidence/execution-result.json"}, + {"id": "validators", "path": "evidence/validator-results.json"}, + ] + assert package["hashes"]["sql_sha256"] + assert package["hashes"]["package_sha256"] + assert validate_report_package(package)["valid"] is True + + +def test_report_package_hash_is_deterministic(tmp_path: Path): + harness = _accepted_report_run(tmp_path) + + first = build_report_package(harness.run_dir) + second = build_report_package(harness.run_dir) + + assert first["hashes"]["package_sha256"] == second["hashes"]["package_sha256"] + assert first["package_id"] == second["package_id"] + + +def test_large_row_count_uses_summary_plus_preview_policy(tmp_path: Path): + harness = _accepted_report_run(tmp_path) + preview_rows = [ + {"department": "管理部", "amount": 1000}, + {"department": "研發部", "amount": 2500}, + {"department": "業務部", "amount": 3000}, + ] + harness.update_state( + validator_results=_sql_gate_validator_results(), + blocking_repair_request=None, + ) + harness.write_data_preview( + { + "rows": preview_rows, + "columns": ["department", "amount"], + "row_count": 6000, + "aggregates": {"amount_sum": 6500}, + "excluded_rows": [], + } + ) + + package = build_report_package(harness.run_dir) + + assert package["data_profile"]["embedded_mode"] == "summary_plus_preview" + assert package["data_profile"]["embedded_rows"] == len(preview_rows) + assert package["data_profile"]["full_rows_in_evidence_packet"] is True + + +def test_rewriting_data_preview_invalidates_downstream_delivery_state(tmp_path: Path): + harness = _accepted_report_run(tmp_path) + + harness.update_state( + validator_results=_sql_gate_validator_results(), + blocking_repair_request=None, + ) + harness.write_data_preview( + { + "rows": [{"department": "客服部", "amount": 9000}], + "columns": ["department", "amount"], + "row_count": 1, + "aggregates": {"amount_sum": 9000}, + "excluded_rows": [], + } + ) + + package = build_report_package(harness.run_dir) + result = validate_report_package(package) + state = harness.state() + + assert result["valid"] is False + assert "delivery_gate" in result["errors"] + assert state["user_confirmations"]["sql_review"] == "同意查詢" + assert "report_selection" not in state["user_confirmations"] + assert "report_draft" not in state["user_confirmations"] + assert "final_review" not in state["user_confirmations"] + assert "report_selection" not in {item["checkpoint"] for item in state["checkpoints"]} + assert "report_draft" not in {item["checkpoint"] for item in state["checkpoints"]} + assert "final_review" not in {item["checkpoint"] for item in state["checkpoints"]} + assert state["report_type"] is None + assert state["report_design"] is None + assert state["report_options"] == {} + assert state["validator_results"] == [] + + +def test_credentials_and_unconfirmed_delivery_are_rejected(tmp_path: Path): + harness = _accepted_report_run(tmp_path) + harness.update_state(db_connection_string="server=prod;password=secret") + harness.confirm("final_review", "回到初稿") + + package = build_report_package(harness.run_dir) + result = validate_report_package(package) + + assert result["valid"] is False + assert "credentials" in result["errors"] + assert "delivery_gate" in result["errors"] diff --git a/tests/skill_scripts/test_report_scaffold.py b/tests/skill_scripts/test_report_scaffold.py new file mode 100644 index 0000000..e990e94 --- /dev/null +++ b/tests/skill_scripts/test_report_scaffold.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +from skill_scripts.report_scaffold import ( + scaffold_report_workspace, + validate_report_protocol, + write_generated_report_section, +) + + +def create_minimal_template(template_dir: Path) -> None: + (template_dir / "report" / "sections").mkdir(parents=True) + (template_dir / "report" / "raw-blocks").mkdir() + (template_dir / "report" / "components").mkdir() + (template_dir / "report" / "payload").mkdir() + (template_dir / "package.json").write_text( + json.dumps({"private": True, "type": "module"}), + encoding="utf-8", + ) + (template_dir / "index.html").write_text( + '
    ', + encoding="utf-8", + ) + (template_dir / "report" / "Report.tsx").write_text( + 'export default function Report() { return
    ; }\n', + encoding="utf-8", + ) + + +def test_scaffold_creates_one_section_per_file_workspace(tmp_path: Path): + run_dir = tmp_path / "run-001" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + + result = scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary", "kpi-overview", "data-table"], + payload={"approved_query_result": {"rows": []}}, + ) + + assert (run_dir / "report" / "Report.tsx").exists() + assert (run_dir / "report" / "sections" / "01-executive-summary.tsx").exists() + assert (run_dir / "report" / "sections" / "02-kpi-overview.tsx").exists() + assert (run_dir / "report" / "sections" / "03-data-table.tsx").exists() + assert (run_dir / "report" / "payload" / "approved-query-result.json").exists() + assert result["section_count"] == 3 + + +def test_cli_scaffold_report_builds_default_five_section_workspace(tmp_path: Path): + run_dir = tmp_path / "run-002" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + + result = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "scaffold-report", + "--run-dir", + str(run_dir), + "--design", + "financial-control", + "--template-dir", + str(template_dir), + ], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "scaffolded" + assert payload["section_count"] == 5 + assert (run_dir / "report" / "sections" / "05-recommendations.tsx").exists() + + +def test_scaffold_refuses_existing_report_files_without_force(tmp_path: Path): + run_dir = tmp_path / "run-existing" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + (run_dir / "report").mkdir(parents=True) + report_path = run_dir / "report" / "Report.tsx" + report_path.write_text("export default function ExistingReport() { return null; }\n", encoding="utf-8") + + with pytest.raises(FileExistsError, match="Report.tsx"): + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": []}}, + ) + + assert "ExistingReport" in report_path.read_text(encoding="utf-8") + + +def test_scaffold_refuses_existing_payload_without_force(tmp_path: Path): + run_dir = tmp_path / "run-existing-payload" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + (run_dir / "report" / "payload").mkdir(parents=True) + payload_path = run_dir / "report" / "payload" / "approved-query-result.json" + payload_path.write_text('{"stale": true}\n', encoding="utf-8") + + with pytest.raises(FileExistsError, match="approved-query-result.json"): + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": []}}, + ) + + assert json.loads(payload_path.read_text(encoding="utf-8")) == {"stale": True} + + +def test_scaffold_force_allows_overwriting_existing_report_files(tmp_path: Path): + run_dir = tmp_path / "run-force" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + (run_dir / "report" / "payload").mkdir(parents=True) + (run_dir / "report" / "payload" / "approved-query-result.json").write_text( + '{"stale": true}\n', + encoding="utf-8", + ) + + result = scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": [{"id": 1}]}}, + force=True, + ) + + assert result["section_count"] == 1 + approved = json.loads( + (run_dir / "report" / "payload" / "approved-query-result.json").read_text(encoding="utf-8") + ) + assert approved == {"rows": [{"id": 1}]} + + +def test_scaffold_generates_unique_components_for_duplicate_and_non_ascii_sections(tmp_path: Path): + run_dir = tmp_path / "run-duplicates" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["KPI Overview", "kpi-overview", "Risk & Control", "Risk Control", "管理摘要", "!!!"], + payload={"approved_query_result": {"rows": []}}, + ) + + component_names = [] + for section_file in sorted((run_dir / "report" / "sections").glob("*.tsx")): + match = re.search(r"export function ([A-Za-z][A-Za-z0-9]*)\(", section_file.read_text(encoding="utf-8")) + assert match is not None + component_names.append(match.group(1)) + + assert len(component_names) == 6 + assert len(set(component_names)) == len(component_names) + assert all(re.search(r"\d{2}Section$", name) for name in component_names) + + +def test_validate_report_protocol_rejects_mismatched_import_export_render_linkage(tmp_path: Path): + run_dir = tmp_path / "run-invalid-linkage" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": []}}, + ) + (run_dir / "report" / "Report.tsx").write_text( + "\n".join( + [ + 'import { ExecutiveSummary01Section } from "./sections/01-executive-summary";', + "export default function Report() {", + " return
    ;", + "}", + "", + ] + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="render"): + validate_report_protocol(run_dir) + + +def test_scaffold_preserves_full_payload_context_and_validator_evidence(tmp_path: Path): + run_dir = tmp_path / "run-payload-context" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + payload = { + "approved_query_result": {"rows": [{"amount": 1200}]}, + "validator_evidence": [{"validator": "sql-execution", "status": "pass"}], + "report_context": {"design": "financial-control"}, + } + + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload=payload, + ) + + payload_dir = run_dir / "report" / "payload" + assert json.loads((payload_dir / "approved-query-result.json").read_text(encoding="utf-8")) == { + "rows": [{"amount": 1200}] + } + assert json.loads((payload_dir / "report-context.json").read_text(encoding="utf-8")) == payload + + +def test_cli_scaffold_report_returns_json_error_for_expected_scaffold_errors(tmp_path: Path): + result = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "scaffold-report", + "--run-dir", + str(tmp_path / "run-missing-template"), + "--design", + "financial-control", + "--template-dir", + str(tmp_path / "missing-template"), + ], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + error = json.loads(result.stderr) + assert error["status"] == "error" + assert error["code"] == "scaffold_error" + assert "template" in error["message"].lower() + + +def test_write_generated_report_section_replaces_single_section_and_preserves_protocol(tmp_path: Path): + run_dir = tmp_path / "run-generated-section" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary", "data-table"], + payload={"approved_query_result": {"rows": [{"amount": 1200}]}}, + ) + code = """ +import React from "react"; + +export function ExecutiveSummary01Section() { + const dataRefs = ["approved-query-result.rows.amount"]; + return ( +
    +

    費用摘要

    +

    本段由 LLM 依已確認資料產生。

    +
    + ); +} +""" + + result = write_generated_report_section( + run_dir=run_dir, + section_id="01-executive-summary", + component_name="ExecutiveSummary01Section", + code=code, + mode="repair", + ) + + section_path = Path(result["path"]) + assert section_path.name == "01-executive-summary.tsx" + assert "本段由 LLM" in section_path.read_text(encoding="utf-8") + assert result["status"] == "section_written" + validate_report_protocol(run_dir) + + +def test_write_generated_report_section_rejects_network_env_and_missing_data_refs(tmp_path: Path): + run_dir = tmp_path / "run-unsafe-section" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": []}}, + ) + unsafe_code = """ +import React from "react"; + +export function ExecutiveSummary01Section() { + fetch("https://example.com/report"); + return

    Bad

    ; +} +""" + + with pytest.raises(ValueError, match="network"): + write_generated_report_section( + run_dir=run_dir, + section_id="01-executive-summary", + component_name="ExecutiveSummary01Section", + code=unsafe_code, + mode="repair", + ) + + +def test_cli_generate_and_validate_report_section(tmp_path: Path): + run_dir = tmp_path / "run-cli-generated-section" + template_dir = tmp_path / "template" + create_minimal_template(template_dir) + scaffold_report_workspace( + run_dir=run_dir, + template_dir=template_dir, + sections=["executive-summary"], + payload={"approved_query_result": {"rows": [{"amount": 1200}]}}, + ) + code_file = tmp_path / "section.tsx" + code_file.write_text( + """ +import React from "react"; + +export function ExecutiveSummary01Section() { + const dataRefs = ["approved-query-result.rows.amount"]; + return

    費用摘要

    ; +} +""", + encoding="utf-8", + ) + + generated = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "generate-report-section", + "--run-dir", + str(run_dir), + "--section-id", + "01-executive-summary", + "--component-name", + "ExecutiveSummary01Section", + "--code-file", + str(code_file), + ], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + + assert generated.returncode == 0, generated.stderr + assert json.loads(generated.stdout)["status"] == "section_written" + + validated = subprocess.run( + [ + sys.executable, + "-m", + "skill_scripts.cli_report_harness", + "validate-report-section", + "--run-dir", + str(run_dir), + "--section-id", + "01-executive-summary", + ], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + + assert validated.returncode == 0, validated.stderr + assert json.loads(validated.stdout)["status"] == "section_valid" diff --git a/tests/skill_scripts/test_report_sql_builder.py b/tests/skill_scripts/test_report_sql_builder.py new file mode 100644 index 0000000..5b632a7 --- /dev/null +++ b/tests/skill_scripts/test_report_sql_builder.py @@ -0,0 +1,112 @@ +from dataclasses import replace + +from skill_scripts.excel_intake import ( + DatabaseFieldRequirement, + FormulaFieldRequirement, + ReportFieldRequirement, + WorkbookRequirement, +) +from skill_scripts.report_sql_builder import ReportSqlFilter, ReportSqlSort, build_report_select_sql + + +def _bundle(): + return { + "modules": [{"ModuleID": "ACP", "ModuleName": "應付管理系統"}], + "tables": [ + {"DB": "WFDB.dbo.", "TableID": "ACPTA", "TableName": "單頭", "ModuleID": "ACP"}, + {"DB": "WFDB.dbo.", "TableID": "ACPTB", "TableName": "單身", "ModuleID": "ACP"}, + ], + "fields": [ + {"TableID": "ACPTA", "ID": "TA001", "FieldName": "單別"}, + {"TableID": "ACPTA", "ID": "TA002", "FieldName": "單號"}, + {"TableID": "ACPTA", "ID": "TA003", "FieldName": "日期"}, + {"TableID": "ACPTA", "ID": "TA018", "FieldName": "作廢"}, + {"TableID": "ACPTA", "ID": "TA024", "FieldName": "確認"}, + {"TableID": "ACPTB", "ID": "TB001", "FieldName": "單別"}, + {"TableID": "ACPTB", "ID": "TB002", "FieldName": "單號"}, + {"TableID": "ACPTB", "ID": "TB013", "FieldName": "科目"}, + {"TableID": "ACPTB", "ID": "TB014", "FieldName": "部門"}, + {"TableID": "ACPTB", "ID": "TB017", "FieldName": "未稅"}, + {"TableID": "ACPTB", "ID": "TB018", "FieldName": "稅額"}, + ], + "index_keys": [ + {"TableName": "ACPTA", "IndexColumnName": "TA001+TA002", "isPrimaryKey": "1"}, + {"TableName": "ACPTB", "IndexColumnName": "TB001+TB002+TB003", "isPrimaryKey": "1"}, + ], + } + + +def _requirement(): + return WorkbookRequirement( + database_fields=[ + DatabaseFieldRequirement("ACPTA", "TA001", "單別"), + DatabaseFieldRequirement("ACPTA", "TA002", "單號"), + DatabaseFieldRequirement("ACPTA", "TA003", "日期"), + DatabaseFieldRequirement("ACPTA", "TA018", "作廢"), + DatabaseFieldRequirement("ACPTA", "TA024", "確認"), + DatabaseFieldRequirement("ACPTB", "TB001", "單別"), + DatabaseFieldRequirement("ACPTB", "TB002", "單號"), + DatabaseFieldRequirement("ACPTB", "TB013", "科目"), + DatabaseFieldRequirement("ACPTB", "TB014", "部門"), + DatabaseFieldRequirement("ACPTB", "TB017", "未稅"), + DatabaseFieldRequirement("ACPTB", "TB018", "稅額"), + ], + formula_fields=[FormulaFieldRequirement("總額", "=未稅+稅額")], + report_fields=[ + ReportFieldRequirement("dept", "部門"), + ReportFieldRequirement("account", "科目"), + ReportFieldRequirement("untaxed", "", "=SUM(未稅)"), + ReportFieldRequirement("tax", "", "=SUM(稅額)"), + ReportFieldRequirement("total", "", "=SUM(總額)"), + ReportFieldRequirement("total_pct", "", "=總額/SUM(總額)*100"), + ], + formula_lineage=[], + warnings=[], + ) + + +def test_build_report_select_sql_uses_uploaded_fields_formulas_and_schema_relationships(): + sql = build_report_select_sql( + "管理報表", + _bundle(), + _requirement(), + filters=[ + ReportSqlFilter("日期", ">=", "20260101"), + ReportSqlFilter("日期", "<=", "20260331"), + ReportSqlFilter("作廢", "not_equals_or_blank", "Y"), + ReportSqlFilter("確認", "=", "Y"), + ], + sorts=[ReportSqlSort("total", "DESC")], + ) + + assert "FROM [WFDB].[dbo].[ACPTA] header" in sql + assert "JOIN [WFDB].[dbo].[ACPTB] detail" in sql + assert "header.[TA001] = detail.[TB001]" in sql + assert "detail.[TB014] AS dept" in sql + assert "SUM(detail.[TB017]) AS untaxed" in sql + assert "SUM(detail.[TB017] + detail.[TB018]) AS total" in sql + assert "grand.grand_total_amount" in sql + assert "ISNULL(header.[TA018], '') <> 'Y'" in sql + assert "ORDER BY total DESC" in sql + + +def test_build_report_select_sql_fails_when_uploaded_field_is_not_in_schema(): + requirement = _requirement() + requirement.database_fields[0] = replace(requirement.database_fields[0], field="BAD") + + try: + build_report_select_sql("管理報表", _bundle(), requirement, filters=[]) + assert False, "expected schema validation failure" + except ValueError as exc: + assert str(exc) == "SCHEMA_FIELD_NOT_FOUND:ACPTA.BAD" + + +def test_build_report_select_sql_fails_without_relationship_for_multiple_tables(): + bundle = _bundle() + bundle["index_keys"] = [] + + try: + build_report_select_sql("管理報表", bundle, _requirement(), filters=[]) + assert False, "expected relationship validation failure" + except ValueError as exc: + assert str(exc) == "SCHEMA_RELATIONSHIP_NOT_FOUND:ACPTA,ACPTB" diff --git a/tests/skill_scripts/test_schema_context_builder.py b/tests/skill_scripts/test_schema_context_builder.py new file mode 100644 index 0000000..529565f --- /dev/null +++ b/tests/skill_scripts/test_schema_context_builder.py @@ -0,0 +1,26 @@ +from skill_scripts.schema_context_builder import build_context_slice + + +def test_build_context_slice_returns_topk_tables_and_columns(): + bundle = { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + {"TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "ModuleID": "ACT", "DB": "VPIC1.dbo."}, + {"TableID": "ACTMJ", "TableName": "科目/部門預算單頭檔", "ModuleID": "ACT", "DB": "VPIC1.dbo."}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán", "Type": "C", "Length": 4}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ", "Type": "N", "Length": 21.6}, + ], + "index_keys": [ + {"TableName": "ACTMJ", "IndexColumnName": "MJ001+MJ002+MJ003+MJ005", "isPrimaryKey": "1"}, + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + ctx = build_context_slice("查詢2026年的工程預算明細,輸出會計年度與期預算", bundle, top_k=2) + assert "tables" in ctx + assert "columns" in ctx + assert any(t["TableID"] == "ACTMK" for t in ctx["tables"]) + assert "ACTMK" in ctx["columns"] + assert any(col["ID"] == "MK002" for col in ctx["columns"]["ACTMK"]) diff --git a/tests/skill_scripts/test_schema_loader.py b/tests/skill_scripts/test_schema_loader.py new file mode 100644 index 0000000..4c7bce0 --- /dev/null +++ b/tests/skill_scripts/test_schema_loader.py @@ -0,0 +1,31 @@ +import pathlib + +from skill_scripts.schema_loader import load_schema_bundle + + +def test_load_schema_bundle_has_required_keys(tmp_path: pathlib.Path): + source = tmp_path / "_Source" + source.mkdir() + (source / "MoudleName.json").write_text("[]", encoding="utf-8") + (source / "TableName.json").write_text("[]", encoding="utf-8") + (source / "TableStructure.json").write_text("[]", encoding="utf-8") + (source / "TableIndexKey.json").write_text("[]", encoding="utf-8") + + bundle = load_schema_bundle(str(source)) + assert set(bundle.keys()) == {"modules", "tables", "fields", "index_keys"} + + +def test_load_schema_bundle_smoke_real_source_has_expected_schema_keys(): + bundle = load_schema_bundle("_Source") + assert bundle["tables"] + assert bundle["fields"] + assert bundle["index_keys"] + + field = bundle["fields"][0] + assert {"TableID", "ID", "Type", "Length", "FieldName", "NameVietnam", "ModuleID", "DB"}.issubset(field.keys()) + + table = bundle["tables"][0] + assert {"TableID", "TableName", "TableNameViet", "ModuleID", "DB"}.issubset(table.keys()) + + idx = bundle["index_keys"][0] + assert {"TableName", "IndexColumnName", "isPrimaryKey"}.issubset(idx.keys()) diff --git a/tests/skill_scripts/test_schema_metadata.py b/tests/skill_scripts/test_schema_metadata.py new file mode 100644 index 0000000..c1073fa --- /dev/null +++ b/tests/skill_scripts/test_schema_metadata.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from skill_scripts.schema_metadata import SchemaMetadata + + +def test_describe_field_returns_readable_table_and_column_names(): + metadata = SchemaMetadata.from_source_dir("_Source") + + field = metadata.describe_field("ACTML", "ML006") + + assert field["table_id"] == "ACTML" + assert field["column_id"] == "ML006" + assert field["table_name"] == "分類帳檔" + assert field["column_name"] == "明細科目編號" + assert field["metadata_status"] == "ok" + + +def test_describe_missing_field_returns_warning_not_bare_code(): + metadata = SchemaMetadata.from_source_dir("_Source") + + field = metadata.describe_field("ACTML", "ZZ999") + + assert field["table_id"] == "ACTML" + assert field["column_id"] == "ZZ999" + assert field["column_name"] == "schema description missing" + assert field["metadata_status"] == "warning" + assert "ZZ999" in field["business_meaning"] + + +def test_describe_expression_includes_each_input_field(): + metadata = SchemaMetadata.from_source_dir("_Source") + + expression = metadata.describe_expression( + [ + {"table_id": "ACTML", "column_id": "ML007", "reason": "借貸別"}, + {"table_id": "ACTML", "column_id": "ML014", "reason": "原幣金額"}, + ] + ) + + assert expression["metadata_status"] == "ok" + assert expression["inputs"] == [ + { + "table_id": "ACTML", + "table_name": "分類帳檔", + "column_id": "ML007", + "column_name": "借貸別", + "join_reason": "", + "business_meaning": "借貸別", + "metadata_status": "ok", + }, + { + "table_id": "ACTML", + "table_name": "分類帳檔", + "column_id": "ML014", + "column_name": "原幣金額", + "join_reason": "", + "business_meaning": "原幣金額", + "metadata_status": "ok", + }, + ] + + +def test_describe_expression_warns_when_any_input_field_is_missing(): + metadata = SchemaMetadata.from_source_dir("_Source") + + expression = metadata.describe_expression( + [ + {"table_id": "ACTML", "column_id": "ML007", "reason": "借貸別"}, + {"table_id": "ACTML", "column_id": "ZZ999", "reason": "Excel 公式來源欄位"}, + ] + ) + + assert expression["metadata_status"] == "warning" + assert expression["inputs"][0]["metadata_status"] == "ok" + assert expression["inputs"][1]["column_name"] == "schema description missing" + assert expression["inputs"][1]["business_meaning"] == "Excel 公式來源欄位" + assert expression["inputs"][1]["metadata_status"] == "warning" diff --git a/tests/skill_scripts/test_single_html_expense_e2e.py b/tests/skill_scripts/test_single_html_expense_e2e.py new file mode 100644 index 0000000..086f097 --- /dev/null +++ b/tests/skill_scripts/test_single_html_expense_e2e.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from skill_scripts.dynamic_design_brief import build_design_brief +from skill_scripts.html_self_validator import validate_single_html_static +from skill_scripts.single_html_exporter import export_single_html_report +from tests.skill_scripts.expense_report_fixture import EXPECTED_COLUMNS +from tests.skill_scripts.expense_report_fixture import run_expense_analysis_sqlite_e2e + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _package_hash(package: dict) -> str: + candidate = json.loads(json.dumps(package, ensure_ascii=False)) + candidate["hashes"].pop("package_sha256", None) + encoded = json.dumps(candidate, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return _sha256(encoded) + + +def _package_from_expense_result(expense: dict) -> dict: + package = { + "schema_version": "wferp.report-package.v1", + "package_id": "expense-e2e", + "prompt": "費用分析", + "catalog_guardrail": "financial-control", + "sql": {"text": expense["sql"], "validation": expense["sql_safety"]}, + "data_profile": { + "row_count": expense["row_count"], + "columns": expense["columns"], + "embedded_mode": "full_rows", + }, + "datasets": {"columns": expense["columns"], "embedded_rows": expense["rows"]}, + "aggregates": expense["aggregates"], + "excluded_rows": expense["excluded_rows"], + "validator_summary": [ + {"validator": "sqlite_expense_e2e", "status": "pass", "message": "SQLite E2E verified."} + ], + "evidence_index": [{"id": "sql", "path": "delivery/evidence/query.sql"}], + "security": {"source_secret_key_paths": []}, + "hashes": {"sql_sha256": _sha256(expense["sql"])}, + "delivery_gate": {"allowed": True, "blocking_validators": []}, + } + package["hashes"]["package_sha256"] = _package_hash(package) + return package + + +def test_single_html_expense_analysis_e2e(tmp_path: Path): + expense = run_expense_analysis_sqlite_e2e(tmp_path) + package = _package_from_expense_result(expense) + brief = build_design_brief(package) + + result = export_single_html_report(tmp_path, package, brief) + validation = validate_single_html_static(result["html_path"]) + + assert result["status"] == "exported" + assert validation["valid"] is True + assert Path(result["html_path"]).exists() + html = Path(result["html_path"]).read_text(encoding="utf-8") + assert "__WFERP_REPORT_PACKAGE__" in html + assert "費用分析管理報表" in html + assert "圖表證明" in html + assert " dict[str, object]: + harness = _accepted_report_run(tmp_path) + return build_report_package(harness.run_dir) + + +def _brief() -> dict[str, object]: + return { + "schema_version": "wferp.report-design-brief.v1", + "title": "採購單查詢", + "layout": {"sections": ["summary", "table"]}, + } + + +def test_export_single_html_report_writes_delivery_manifest_and_evidence(tmp_path: Path): + result = export_single_html_report(tmp_path, _package(tmp_path), _brief()) + + delivery = tmp_path / "delivery" + html_path = delivery / "report.html" + manifest_path = delivery / "delivery-manifest.json" + evidence_dir = delivery / "evidence" + + assert result["status"] == "exported" + assert Path(result["html_path"]) == html_path + assert html_path.exists() + assert manifest_path.exists() + assert (evidence_dir / "report-package.json").exists() + assert (evidence_dir / "report-design-brief.json").exists() + style_capsule = json.loads((evidence_dir / "report-style-capsule.json").read_text(encoding="utf-8")) + assert style_capsule["style_version"] == "wferp.style-capsule.v1" + assert (evidence_dir / "query.sql").read_text(encoding="utf-8").strip() == ( + "SELECT department, amount FROM expenses" + ) + + html = html_path.read_text(encoding="utf-8") + assert html.startswith("") + assert "__WFERP_REPORT_PACKAGE__" in html + assert "管理摘要" in html + assert "管理分析" in html + assert "建議事項" in html + assert " dict[str, list[dict[str, str]]]: + return { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + {"DB": "wferp_test.dbo.", "TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "TableNameViet": "", "ModuleID": "ACT"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK001", "FieldName": "預算編號", "NameVietnam": ""}, + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán"}, + {"TableID": "ACTMK", "ID": "MK005", "FieldName": "期別", "NameVietnam": ""}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ"}, + ], + "index_keys": [ + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + +def _money(value: object) -> float: + if isinstance(value, Decimal): + return float(value) + return float(cast(SupportsFloat, value)) + + +def test_real_test_db_actmk_fixture_supports_2026_budget_correctness(monkeypatch: pytest.MonkeyPatch) -> None: + if os.getenv("WFERP_RUN_DB_E2E") != "1": + pytest.skip("Set WFERP_RUN_DB_E2E=1 after starting and seeding test_db to run SQL correctness E2E.") + + config = DatabaseConfig.from_env() + assert config.env == "test" + client = DatabaseClient(config) + + contrast_rows = client.execute_readonly("SELECT [MK002],[MK006] FROM [dbo].[ACTMK] WHERE [MK002] = '2025'") + assert len(contrast_rows) == 1 + assert str(contrast_rows[0]["MK002"]).strip() == "2025" + assert _money(contrast_rows[0]["MK006"]) == 999999.0 + + monkeypatch.setenv( + "LLM_MOCK_RESPONSE", + json.dumps( + { + "sql": "SELECT [MK002],[MK006] FROM [dbo].[ACTMK] WHERE [MK002] = '2026' ORDER BY [MK005]", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + ) + result = run_governed_query( + prompt="查詢2026年的工程預算明細", + bundle=_bundle(), + options=RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + execution_expectation=ExecutionExpectation( + required_columns=["MK002", "MK006"], + min_rows=3, + max_rows=3, + aggregates=[AggregateExpectation(operation="sum", column="MK006", expected_value=450000.0)], + ), + ), + db_client=client, + sample_rows_limit=3, + ) + + assert result.evidence.validation_status == "OK" + assert result.evidence.execution_status == "OK" + assert result.evidence.returned_columns == ["MK002", "MK006"] + assert result.evidence.row_count == 3 + assert [str(row["MK002"]).strip() for row in result.evidence.sample_rows] == ["2026", "2026", "2026"] + assert sum(_money(row["MK006"]) for row in result.evidence.sample_rows) == 450000.0 + assert result.evidence.aggregate_checks == [ + {"operation": "sum", "column": "MK006", "expected_value": 450000.0, "tolerance": 0.0} + ] diff --git a/tests/skill_scripts/test_sql_generator.py b/tests/skill_scripts/test_sql_generator.py new file mode 100644 index 0000000..8eaa50f --- /dev/null +++ b/tests/skill_scripts/test_sql_generator.py @@ -0,0 +1,84 @@ +from skill_scripts.sql_generator import generate_select_sql + + +def sample_bundle(): + return { + "modules": [ + {"ModuleID": "PUR", "ModuleName": "採購管理系統"}, + {"ModuleID": "ADM", "ModuleName": "管理維護系統"}, + ], + "tables": [ + {"DB": "DSCSYS.dbo.", "TableID": "PURTA", "TableName": "採購單單頭檔", "TableNameViet": "HS tiêu đề đơn mua", "ModuleID": "PUR", "ModuleName": "採購管理系統"}, + {"DB": "DSCSYS.dbo.", "TableID": "ADMMC", "TableName": "使用者資料檔", "TableNameViet": "HS dữ liệu người dùng", "ModuleID": "ADM", "ModuleName": "管理維護系統"}, + ], + "fields": [], + "index_keys": [], + } + + +def test_generate_select_sql_returns_select_statement(): + sql = generate_select_sql("查詢採購單", sample_bundle()) + assert sql.startswith("SELECT TOP 50") + assert "FROM [DSCSYS].[dbo].[PURTA]" in sql + + +def test_generate_select_sql_parses_top_from_prompt_variants(): + sql_zh = generate_select_sql("查詢採購單前 20 筆", sample_bundle()) + assert sql_zh.startswith("SELECT TOP 20") + + sql_en = generate_select_sql("top 15 purchase orders", sample_bundle()) + assert sql_en.startswith("SELECT TOP 15") + + +def test_generate_select_sql_fallback_to_admmc(): + sql = generate_select_sql("查詢未知資料", sample_bundle()) + assert "FROM [DSCSYS].[dbo].[ADMMC]" in sql + + +def test_generate_select_sql_rejects_non_select_intent(): + try: + generate_select_sql("更新採購單狀態", sample_bundle()) + assert False, "expected ValueError" + except ValueError as exc: + assert str(exc) == "NON_SELECT_INTENT" + + +def test_generate_select_sql_deterministic_on_tie_scores(): + bundle = { + "modules": [{"ModuleID": "INV", "ModuleName": "庫存管理系統"}], + "tables": [ + {"DB": "DSCSYS.dbo.", "TableID": "INVTB", "TableName": "庫存交易檔", "TableNameViet": "", "ModuleID": "INV"}, + {"DB": "DSCSYS.dbo.", "TableID": "INVTA", "TableName": "庫存主檔", "TableNameViet": "", "ModuleID": "INV"}, + ], + "fields": [], + "index_keys": [], + } + sql = generate_select_sql("查詢庫存管理系統資料", bundle) + assert "FROM [DSCSYS].[dbo].[INVTA]" in sql + + +def test_generate_select_sql_budget_detail_with_year_uses_actmk_and_year_filter(): + bundle = { + "modules": [ + {"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}, + {"ModuleID": "ADM", "ModuleName": "管理維護系統"}, + ], + "tables": [ + {"DB": "VPIC1.dbo.", "TableID": "ACTMJ", "TableName": "科目/部門預算單頭檔", "TableNameViet": "", "ModuleID": "ACT"}, + {"DB": "VPIC1.dbo.", "TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "TableNameViet": "", "ModuleID": "ACT"}, + {"DB": "DSCSYS.dbo.", "TableID": "ADMMC", "TableName": "使用者資料檔", "TableNameViet": "", "ModuleID": "ADM"}, + ], + "fields": [], + "index_keys": [], + } + sql = generate_select_sql("查詢2026年的工程預算明細", bundle) + assert sql.startswith("SELECT * FROM [VPIC1].[dbo].[ACTMK]") + assert "WHERE [MK002] = '2026'" in sql + + +def test_generate_select_sql_honors_default_db_override(monkeypatch): + monkeypatch.setenv("WFERP_DEFAULT_DB", "CHD") + sql = generate_select_sql("查詢採購單前 20 筆", sample_bundle()) + assert sql.startswith("SELECT TOP 20") + assert "FROM [CHD].[dbo].[PURTA]" in sql + diff --git a/tests/skill_scripts/test_sql_router.py b/tests/skill_scripts/test_sql_router.py new file mode 100644 index 0000000..a396455 --- /dev/null +++ b/tests/skill_scripts/test_sql_router.py @@ -0,0 +1,386 @@ +import json + +from skill_scripts.execution_validator import ExecutionExpectation +from skill_scripts.sql_router import RoutingOptions, route_generate_sql + + +def _bundle(): + return { + "modules": [{"ModuleID": "ACT", "ModuleName": "會計總帳管理系統"}], + "tables": [ + {"DB": "VPIC1.dbo.", "TableID": "ACTMK", "TableName": "科目/部門預算單身檔", "TableNameViet": "", "ModuleID": "ACT"}, + {"DB": "DSCSYS.dbo.", "TableID": "ADMMC", "TableName": "使用者資料檔", "TableNameViet": "", "ModuleID": "ADM"}, + ], + "fields": [ + {"TableID": "ACTMK", "ID": "MK002", "FieldName": "會計年度", "NameVietnam": "Năm kế toán"}, + {"TableID": "ACTMK", "ID": "MK006", "FieldName": "期預算", "NameVietnam": "Dự toán kỳ"}, + ], + "index_keys": [ + {"TableName": "ACTMK", "IndexColumnName": "MK001+MK002+MK003+MK004+MK005", "isPrimaryKey": "1"}, + ], + } + + +def _assert_runtime_error(fn, expected_substring: str): + try: + fn() + assert False, "expected RuntimeError" + except RuntimeError as exc: + assert expected_substring in str(exc) + + +def test_route_generate_sql_rule_mode_returns_deterministic_sql(): + sql, meta = route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions(mode="rule"), + ) + assert sql.startswith("SELECT") + assert meta["route"] == "rule" + + +def test_route_generate_sql_raises_when_provider_not_configured(): + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions(mode="llm-first", llm_provider="none"), + ), + "LLM_PROVIDER_NOT_CONFIGURED", + ) + + +def test_route_generate_sql_shadow_mode_returns_rule_and_candidate(monkeypatch): + mock_payload = { + "sql": "SELECT * FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.91, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + sql, meta = route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions(mode="shadow", llm_provider="mock", llm_model="mock", min_confidence=0.5), + ) + assert sql.startswith("SELECT") + assert meta["route"] == "shadow_rule" + assert "candidate_sql" in meta + + +def test_route_generate_sql_raises_after_llm_repair_attempts_exhausted(monkeypatch): + mock_payload = { + "sql": "SELECT MK002 FROM ACTMK", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.91, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions(mode="llm-first", llm_provider="mock", llm_model="mock", min_confidence=0.5, llm_repair_attempts=1), + ), + "LLM_REPAIR_FAILED:TABLE_REFERENCE_FORMAT_INVALID", + ) + + +def test_route_generate_sql_llm_repair_succeeds_without_rule_fallback(monkeypatch): + responses = [ + json.dumps( + { + "sql": "SELECT MK002 FROM ACTMK", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + json.dumps( + { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": ["fixed brackets"], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + ] + + def _fake_call_llm(provider: str, model: str, prompt_text: str, timeout_sec: float = 30.0) -> str: + assert provider == "mock" + return responses.pop(0) + + monkeypatch.setattr("skill_scripts.sql_router.call_llm", _fake_call_llm) + + sql, meta = route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions(mode="llm-first", llm_provider="mock", llm_model="mock", min_confidence=0.5, llm_repair_attempts=2), + ) + assert sql.startswith("SELECT [MK002],[MK006]") + assert meta["route"] == "llm" + + +class _FakeDbClient: + def __init__(self, env: str = "test"): + self.config = type("Cfg", (), {"env": env})() + + def execute_readonly(self, sql: str): + return [{"MK002": "2026", "MK006": 1000.0}] + + def health_check(self): + return True, "OK" + + +def test_route_generate_sql_llm_path_with_execution_validation(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + sql, meta = route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + ), + db_client=_FakeDbClient(), + ) + assert sql.startswith("SELECT") + assert meta["route"] == "llm" + + +class _FakeProdDbClient(_FakeDbClient): + def __init__(self): + super().__init__(env="prod") + + +def test_route_generate_sql_blocks_execution_validation_on_non_test_env(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002"], min_rows=1), + ), + db_client=_FakeProdDbClient(), + ), + "DB_ENV_NOT_TEST", + ) + + +class _FakeBrokenDbClient(_FakeDbClient): + def execute_readonly(self, sql: str): + raise RuntimeError("connection failed") + + +class _FakeNonRuntimeBrokenDbClient(_FakeDbClient): + def execute_readonly(self, sql: str): + raise ValueError("connection failed") + + +class _FakeCustomDbClientNoConfig: + def execute_readonly(self, sql: str): + return [{"MK002": "2026", "MK006": 1000.0}] + + def health_check(self): + return True, "OK" + + +class _FakeExecMismatchThenPassDbClient(_FakeDbClient): + def __init__(self, env: str = "test"): + super().__init__(env=env) + self.calls = 0 + + def execute_readonly(self, sql: str): + self.calls += 1 + if self.calls == 1: + return [{"MK002": "2026"}] + return [{"MK002": "2026", "MK006": 1000.0}] + + +class _FakeExecMismatchAlwaysDbClient(_FakeDbClient): + def execute_readonly(self, sql: str): + return [{"MK002": "2026"}] + + +def test_route_generate_sql_falls_back_when_execution_validation_errors(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002"], min_rows=1), + allow_non_test_execution=True, + llm_repair_attempts=1, + ), + db_client=_FakeBrokenDbClient(), + ), + "LLM_REPAIR_FAILED:EXECUTION_VALIDATION_ERROR", + ) + + +def test_route_generate_sql_falls_back_when_execution_validation_raises_non_runtime_error(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002"], min_rows=1), + allow_non_test_execution=True, + llm_repair_attempts=1, + ), + db_client=_FakeNonRuntimeBrokenDbClient(), + ), + "LLM_REPAIR_FAILED:EXECUTION_VALIDATION_ERROR", + ) + + +def test_route_generate_sql_blocks_execution_validation_for_client_without_env_config(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002"], min_rows=1), + ), + db_client=_FakeCustomDbClientNoConfig(), + ), + "DB_ENV_NOT_TEST", + ) + + +def test_route_generate_sql_repairs_after_execution_validation_mismatch(monkeypatch): + responses = [ + json.dumps( + { + "sql": "SELECT [MK002] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + json.dumps( + { + "sql": "SELECT [MK002],[MK006] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": ["fixed missing column"], + "confidence": 0.95, + }, + ensure_ascii=False, + ), + ] + prompts: list[str] = [] + + def _fake_call_llm(provider: str, model: str, prompt_text: str, timeout_sec: float = 30.0) -> str: + prompts.append(prompt_text) + return responses.pop(0) + + monkeypatch.setattr("skill_scripts.sql_router.call_llm", _fake_call_llm) + + sql, meta = route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + llm_repair_attempts=2, + ), + db_client=_FakeExecMismatchThenPassDbClient(), + ) + + assert sql.startswith("SELECT [MK002],[MK006]") + assert meta["route"] == "llm" + assert len(prompts) >= 2 + assert "Previous SQL:" in prompts[1] + assert "Failure code: MISSING_REQUIRED_COLUMN" in prompts[1] + + +def test_route_generate_sql_raises_when_execution_validation_mismatch_persists(monkeypatch): + mock_payload = { + "sql": "SELECT [MK002] FROM [VPIC1].[dbo].[ACTMK] WHERE [MK002] = '2026'", + "used_tables": ["ACTMK"], + "assumptions": [], + "confidence": 0.95, + } + monkeypatch.setenv("LLM_MOCK_RESPONSE", json.dumps(mock_payload, ensure_ascii=False)) + + _assert_runtime_error( + lambda: route_generate_sql( + "查詢2026年的工程預算明細", + _bundle(), + RoutingOptions( + mode="llm-first", + llm_provider="mock", + llm_model="mock", + min_confidence=0.5, + validate_execution=True, + execution_expectation=ExecutionExpectation(required_columns=["MK002", "MK006"], min_rows=1), + llm_repair_attempts=1, + ), + db_client=_FakeExecMismatchAlwaysDbClient(), + ), + "LLM_REPAIR_FAILED:MISSING_REQUIRED_COLUMN", + ) diff --git a/tests/skill_scripts/test_sqlite_enrichment.py b/tests/skill_scripts/test_sqlite_enrichment.py new file mode 100644 index 0000000..bbcc1f8 --- /dev/null +++ b/tests/skill_scripts/test_sqlite_enrichment.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from skill_scripts.formula_sqlite_translator import translate_formula +from skill_scripts.sqlite_enrichment import run_enrichment +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace + + +def test_run_enrichment_creates_enriched_table_from_raw_and_lookup(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path / "run-001", run_id="run-001") + workspace.write_raw_rows( + [ + { + "account_code": "6111", + "original_debit": 100.0, + "original_credit": 0.0, + "local_debit": 459.58, + "local_credit": 0.0, + }, + { + "account_code": "6113", + "original_debit": 0.0, + "original_credit": 25.0, + "local_debit": 0.0, + "local_credit": 114.895, + }, + ] + ) + lookup_table = workspace.register_lookup_table( + "lookup_account_category", + [ + {"account_code": "6111", "expense_category": "8.租金支出"}, + {"account_code": "6113", "expense_category": "9001.旅費"}, + ], + ) + + result = run_enrichment( + workspace, + computed_columns=[ + {"name": "amount_original", "expression": '"original_debit" - "original_credit"'}, + {"name": "amount_local", "expression": '"local_debit" - "local_credit"'}, + {"name": "rate_2", "expression": "4.5958"}, + {"name": "amount_ntd", "expression": '4.5958 * ("local_debit" - "local_credit")'}, + ], + lookup_columns=[ + { + "name": "expense_category", + "lookup_table": lookup_table, + "raw_key": "account_code", + "lookup_key": "account_code", + "lookup_value": "expense_category", + } + ], + ) + + assert result["status"] == "enriched" + assert result["enriched_table"] == workspace.enriched_table + assert result["enriched_row_count"] == 2 + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + f'SELECT account_code, amount_original, expense_category FROM "{workspace.enriched_table}" ORDER BY account_code' + ).fetchall() + assert rows == [("6111", 100.0, "8.租金支出"), ("6113", -25.0, "9001.旅費")] + + +def test_run_enrichment_quotes_identifiers_and_updates_manifest(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path / "run-quoted", run_id="run quoted") + workspace.write_raw_rows( + [ + {'account "code"': "6111", "original debit": 100.0}, + {'account "code"': "9999", "original debit": 50.0}, + ] + ) + lookup_table = workspace.register_lookup_table( + 'lookup "category"', + [{'account "code"': "6111", "expense category": "8.租金支出"}], + ) + + result = run_enrichment( + workspace, + computed_columns=[ + {"name": 'copied "amount"', "expression": 'raw."original debit"'}, + ], + lookup_columns=[ + { + "name": "expense category", + "lookup_table": lookup_table, + "raw_key": 'account "code"', + "lookup_key": 'account "code"', + "lookup_value": "expense category", + } + ], + ) + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + ( + f'SELECT "account ""code""", "copied ""amount""", "expense category" ' + f'FROM "{workspace.enriched_table}" ORDER BY "account ""code"""' + ) + ).fetchall() + + assert rows == [("6111", 100.0, "8.租金支出"), ("9999", 50.0, None)] + manifest = workspace.manifest() + assert manifest["enriched_row_count"] == 2 + assert manifest["enrichment_sql"] == result["sql"] + assert 'LEFT JOIN "' in manifest["enrichment_sql"] + assert '"account ""code"""' in manifest["enrichment_sql"] + + +def test_run_enrichment_rejects_unvalidated_computed_expression(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path / "run-injection", run_id="run-injection") + workspace.write_raw_rows([{"account_code": "6111", "amount": 100.0}]) + + with pytest.raises(ValueError, match="Unsafe computed expression"): + run_enrichment( + workspace, + computed_columns=[ + {"name": "bad", "expression": '"amount"; DROP TABLE raw_ledger; --'}, + ], + lookup_columns=[], + ) + + for expression in [ + 'raw."amount" + (SELECT count(*) FROM sqlite_master)', + 'raw."amount" + random()', + '"amount" OR 1=1', + ]: + with pytest.raises(ValueError, match="Unsafe computed expression"): + run_enrichment( + workspace, + computed_columns=[ + {"name": "bad", "expression": expression}, + ], + lookup_columns=[], + ) + + +def test_translated_formula_expression_works_with_lookup_join_without_ambiguous_columns(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path / "run-formula-join", run_id="run-formula-join") + workspace.write_raw_rows( + [ + {"account_code": "570101", "voucher_no": "ABCDE123456Z"}, + {"account_code": "6111", "voucher_no": "FGHIJ654321Z"}, + ] + ) + lookup_table = workspace.register_lookup_table( + "lookup_account_category", + [ + {"account_code": "570101", "expense_category": "57類"}, + {"account_code": "6111", "expense_category": "其他"}, + ], + ) + + voucher_segment = translate_formula("=MID(F2,6,6)", {"F": 'raw."voucher_no"'}) + account_group = translate_formula( + '=IF(LEFT(A2,2)="57","57",LEFT(A2,1))', + {"A": 'raw."account_code"'}, + ) + result = run_enrichment( + workspace, + computed_columns=[ + {"name": "voucher_segment", "expression": voucher_segment}, + {"name": "account_group", "expression": account_group}, + ], + lookup_columns=[ + { + "name": "expense_category", + "lookup_table": lookup_table, + "raw_key": "account_code", + "lookup_key": "account_code", + "lookup_value": "expense_category", + } + ], + ) + + assert result["enriched_row_count"] == 2 + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + ( + f'SELECT account_code, voucher_segment, account_group, expense_category ' + f'FROM "{workspace.enriched_table}" ORDER BY account_code' + ) + ).fetchall() + + assert rows == [ + ("570101", "123456", "57", "57類"), + ("6111", "654321", "6", "其他"), + ] diff --git a/tests/skill_scripts/test_sqlite_enrichment_expense_e2e.py b/tests/skill_scripts/test_sqlite_enrichment_expense_e2e.py new file mode 100644 index 0000000..a982e91 --- /dev/null +++ b/tests/skill_scripts/test_sqlite_enrichment_expense_e2e.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import sqlite3 +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape +import json + +from skill_scripts.formula_sqlite_translator import translate_formula +from skill_scripts.llm_workbook_classifier import classify_workbook_with_llm +from skill_scripts.sqlite_enrichment import run_enrichment +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace +from skill_scripts.workbook_lookup_importer import import_lookup_sheet + + +def _column_name(index: int) -> str: + name = "" + while index: + index, remainder = divmod(index - 1, 26) + name = chr(65 + remainder) + name + return name + + +def _sheet_xml(rows: list[list[str | int | tuple[str, str]]]) -> str: + rendered_rows = [] + for row_index, row in enumerate(rows, start=1): + cells = [] + for column_index, value in enumerate(row, start=1): + cell_ref = f"{_column_name(column_index)}{row_index}" + if isinstance(value, tuple) and value[0] == "formula": + cells.append(f'{escape(value[1].lstrip("="))}') + else: + cells.append( + f'{escape(str(value))}' + ) + rendered_rows.append(f'{"".join(cells)}') + return ( + '' + '' + f'{"".join(rendered_rows)}' + "" + ) + + +def _expense_workbook(path: Path) -> None: + sheets = [ + ( + "明細帳", + [ + [ + "科目編號", + "科目名稱", + "部門代號", + "部門名稱", + "傳票日期", + "本幣借方金額", + "本幣貸方金額", + "金額-本幣", + "費用類別", + ], + [ + "6111", + "租金支出", + "ADM", + "行政部", + "20260105", + 1000, + 0, + ("formula", "=F2-G2"), + ("formula", "=VLOOKUP(A2,對照表!A:B,2,0)"), + ], + [ + "6113", + "旅費", + "RND", + "研發部", + "20260106", + 300, + 0, + ("formula", "=F3-G3"), + ("formula", "=VLOOKUP(A3,對照表!A:B,2,0)"), + ], + ], + ), + ( + "對照表", + [ + ["科目編號", "費用類別"], + ["公司別", "AIS"], + ["科目編號", "費用類別"], + ["6111", "8.租金支出"], + ["6113", "9001.旅費"], + ], + ), + ] + workbook_sheets = [] + rels = [] + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + '' + + "".join( + f'' + for index, _ in enumerate(sheets, start=1) + ) + + "", + ) + archive.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + for index, (name, rows) in enumerate(sheets, start=1): + workbook_sheets.append( + f'' + ) + rels.append( + f'' + ) + archive.writestr(f"xl/worksheets/sheet{index}.xml", _sheet_xml(rows)) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'{"".join(workbook_sheets)}' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + f'{"".join(rels)}' + "", + ) + + +def test_expense_sqlite_enrichment_e2e_with_llm_classification_contract(tmp_path: Path, monkeypatch): + workbook = tmp_path / "expense.xlsx" + _expense_workbook(workbook) + monkeypatch.setenv( + "LLM_MOCK_RESPONSE", + json.dumps( + { + "candidate_tables": [ + { + "table_id": "ACTML", + "table_name": "分類帳檔", + "reason": "費用明細帳交易列最可能在分類帳檔", + "confidence": 0.9, + } + ], + "column_mappings": [ + { + "excel_column": "A", + "excel_header": "科目編號", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML006", + "fields": [{"table_id": "ACTML", "column_id": "ML006", "business_meaning": "明細科目編號"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.9, + "reason": "LLM selected ACTML before mapping fields.", + "risks": [], + }, + { + "excel_column": "C", + "excel_header": "部門代號", + "classification": "db_source_field", + "processing_location": "formal_db_sql", + "source_expression": "ACTML.ML010", + "fields": [{"table_id": "ACTML", "column_id": "ML010", "business_meaning": "部門"}], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.9, + "reason": "LLM selected ACTML before mapping fields.", + "risks": [], + }, + { + "excel_column": "H", + "excel_header": "金額-本幣", + "classification": "db_derived_field", + "processing_location": "sqlite_enrichment", + "source_expression": "=F2-G2", + "fields": [ + {"table_id": "ACTML", "column_id": "ML007", "business_meaning": "借貸別"}, + {"table_id": "ACTML", "column_id": "ML008", "business_meaning": "本幣金額"}, + ], + "relationship_path": [], + "lookup_sheet": "", + "confidence": 0.86, + "reason": "Excel amount formula must be enriched locally.", + "risks": [], + }, + { + "excel_column": "I", + "excel_header": "費用類別", + "classification": "excel_enrichment_field", + "processing_location": "sqlite_enrichment", + "source_expression": "=VLOOKUP(A2,對照表!A:B,2,0)", + "fields": [], + "relationship_path": [], + "lookup_sheet": "對照表", + "confidence": 0.9, + "reason": "Workbook lookup formula.", + "risks": [], + }, + ], + "assumptions": [], + "confidence": 0.88, + }, + ensure_ascii=False, + ), + ) + classification = classify_workbook_with_llm( + workbook, + source_dir="_Source", + primary_sheet="明細帳", + user_prompt="費用分析", + llm_provider="mock", + llm_model="mock", + ) + by_header = {column["excel_header"]: column for column in classification["columns"]} + assert by_header["科目編號"]["classification"] == "db_source_field" + assert by_header["部門代號"]["classification"] == "db_source_field" + assert by_header["金額-本幣"]["classification"] == "db_derived_field" + assert by_header["金額-本幣"]["processing_location"] == "sqlite_enrichment" + assert by_header["費用類別"]["classification"] == "excel_enrichment_field" + assert by_header["費用類別"]["lookup_sheet"] == "對照表" + amount_expression = translate_formula( + "=F2-G2", + { + "F": '"local_debit"', + "G": '"local_credit"', + }, + ) + + workspace = SQLiteRunWorkspace.create(tmp_path / "run-expense", run_id="expense") + workspace.write_raw_rows( + [ + { + "account_code": "6111", + "account_name": "租金支出", + "department_code": "ADM", + "department_name": "行政部", + "voucher_date": "20260105", + "local_debit": 1000.0, + "local_credit": 0.0, + }, + { + "account_code": "6113", + "account_name": "旅費", + "department_code": "RND", + "department_name": "研發部", + "voucher_date": "20260106", + "local_debit": 300.0, + "local_credit": 0.0, + }, + ] + ) + lookup = import_lookup_sheet( + workbook, + workspace, + sheet_name="對照表", + logical_name="lookup_account_category", + key_column="A", + value_columns={"expense_category": "B"}, + ) + + result = run_enrichment( + workspace, + computed_columns=[{"name": "amount_local", "expression": amount_expression}], + lookup_columns=[ + { + "name": "expense_category", + "lookup_table": lookup["table_name"], + "raw_key": "account_code", + "lookup_key": "account_code", + "lookup_value": "expense_category", + } + ], + ) + + assert result["enriched_row_count"] == 2 + assert lookup["ignored_row_count"] == 3 + with sqlite3.connect(workspace.sqlite_db_path) as conn: + columns = [ + row[1] + for row in conn.execute( + f'PRAGMA table_info("{workspace.enriched_table}")' + ).fetchall() + ] + rows = conn.execute( + ( + f'SELECT department_name, account_code, amount_local, expense_category ' + f'FROM "{workspace.enriched_table}" ORDER BY account_code' + ) + ).fetchall() + department_totals = conn.execute( + ( + f'SELECT department_name, SUM(amount_local) ' + f'FROM "{workspace.enriched_table}" ' + f'GROUP BY department_name ORDER BY department_name' + ) + ).fetchall() + total = conn.execute( + f'SELECT SUM(amount_local) FROM "{workspace.enriched_table}"' + ).fetchone()[0] + + assert columns == [ + "account_code", + "account_name", + "department_code", + "department_name", + "voucher_date", + "local_debit", + "local_credit", + "expense_category", + "amount_local", + ] + assert rows == [ + ("行政部", "6111", 1000.0, "8.租金支出"), + ("研發部", "6113", 300.0, "9001.旅費"), + ] + assert department_totals == [("研發部", 300.0), ("行政部", 1000.0)] + assert total == 1300.0 + manifest = workspace.manifest() + assert Path(manifest["sqlite_db_path"]).is_file() + assert manifest["run_prefix"] in manifest["raw_table"] + assert manifest["run_prefix"] in manifest["enriched_table"] + assert manifest["source_workbook"] == str(workbook) + assert manifest["source_workbook_hash"] + assert manifest["raw_row_count"] == 2 + assert manifest["enriched_row_count"] == 2 + assert manifest["lookup_row_counts"][lookup["table_name"]] == 2 + assert len(manifest["ignored_lookup_rows"][lookup["table_name"]]) == 3 + assert manifest["retention_decision"] == "keep" + assert manifest["cleanup_status"] == "active" + + workspace.cleanup_run_tables() + deleted_manifest = workspace.manifest() + assert deleted_manifest["cleanup_status"] == "deleted" + assert deleted_manifest["retention_decision"] == "delete" + with sqlite3.connect(workspace.sqlite_db_path) as conn: + table_names = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert workspace.raw_table not in table_names + assert workspace.enriched_table not in table_names + assert lookup["table_name"] not in table_names diff --git a/tests/skill_scripts/test_sqlite_readonly_adapter.py b/tests/skill_scripts/test_sqlite_readonly_adapter.py new file mode 100644 index 0000000..cc7de93 --- /dev/null +++ b/tests/skill_scripts/test_sqlite_readonly_adapter.py @@ -0,0 +1,9 @@ +from skill_scripts.sqlite_readonly_adapter import translate_sqlserver_select_to_sqlite + + +def test_translates_any_database_bracketed_schema_identifiers(): + sql = "SELECT * FROM [VPIC1].[dbo].[ACPTA]" + + translated = translate_sqlserver_select_to_sqlite(sql) + + assert translated == 'SELECT * FROM "ACPTA"' diff --git a/tests/skill_scripts/test_sqlite_workspace.py b/tests/skill_scripts/test_sqlite_workspace.py new file mode 100644 index 0000000..8382c94 --- /dev/null +++ b/tests/skill_scripts/test_sqlite_workspace.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace + + +REQUIRED_MANIFEST_KEYS = { + "sqlite_db_path", + "run_prefix", + "raw_table", + "lookup_tables", + "enriched_table", + "raw_row_count", + "enriched_row_count", + "lookup_row_counts", + "ignored_lookup_rows", + "cleanup_status", + "retention_decision", + "created_at", + "residual_risks", + "source_workbook", + "source_workbook_hash", + "formal_db_query_hash", +} + + +def _table_names(db_path: Path) -> set[str]: + with sqlite3.connect(db_path) as conn: + return { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + + +def test_workspace_creates_unique_run_prefix_and_complete_manifest(tmp_path: Path): + first = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + second = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + + assert first.run_prefix != second.run_prefix + assert first.raw_table.startswith(first.run_prefix) + assert first.raw_table.endswith("_raw_ledger") + assert first.enriched_table.startswith(first.run_prefix) + assert first.enriched_table.endswith("_enriched_ledger") + assert first.raw_table != first.enriched_table + assert first.sqlite_db_path == tmp_path / "sqlite" / "wferp-report.sqlite3" + assert first.manifest_path.exists() + + manifest = first.manifest() + assert REQUIRED_MANIFEST_KEYS <= set(manifest) + assert manifest["sqlite_db_path"] == str(first.sqlite_db_path) + assert manifest["run_prefix"] == first.run_prefix + assert manifest["raw_table"] == first.raw_table + assert manifest["lookup_tables"] == [] + assert manifest["enriched_table"] == first.enriched_table + assert manifest["raw_row_count"] == 0 + assert manifest["enriched_row_count"] == 0 + assert manifest["lookup_row_counts"] == {} + assert manifest["ignored_lookup_rows"] == {} + assert manifest["cleanup_status"] == "active" + assert manifest["retention_decision"] == "keep" + assert manifest["created_at"] + assert manifest["residual_risks"] == [] + assert manifest["source_workbook"] is None + assert manifest["source_workbook_hash"] is None + assert manifest["formal_db_query_hash"] is None + + +def test_workspace_writes_raw_rows_to_sqlite_and_updates_manifest(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + + workspace.write_raw_rows( + [ + {"account_code": "6111", "amount": 100, "note": "租金"}, + {"account_code": "6113", "amount": 200.5, "note": "運費"}, + ] + ) + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + f'SELECT account_code, amount, note FROM "{workspace.raw_table}" ORDER BY account_code' + ).fetchall() + assert rows == [("6111", 100, "租金"), ("6113", 200.5, "運費")] + assert workspace.manifest()["raw_row_count"] == 2 + + +def test_register_lookup_table_sanitizes_names_and_records_counts(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + + lookup_table = workspace.register_lookup_table( + 'lookup account"; DROP TABLE unrelated_table; --', + [{"account_code": "6111", "category": "租金"}], + ) + + assert lookup_table.startswith(workspace.run_prefix) + assert lookup_table.endswith("_lookup_account_drop_table_unrelated_table") + assert '"' not in lookup_table + assert ";" not in lookup_table + assert "--" not in lookup_table + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + f'SELECT account_code, category FROM "{lookup_table}"' + ).fetchall() + assert rows == [("6111", "租金")] + + manifest = workspace.manifest() + assert manifest["lookup_tables"] == [lookup_table] + assert manifest["lookup_row_counts"] == {lookup_table: 1} + assert manifest["ignored_lookup_rows"] == {} + + +def test_cleanup_run_tables_only_drops_manifest_tables(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + with sqlite3.connect(workspace.sqlite_db_path) as conn: + conn.execute("CREATE TABLE unrelated_table(id INTEGER)") + conn.execute("CREATE TABLE unrelated_lookup(id INTEGER)") + conn.execute(f'CREATE TABLE "{workspace.enriched_table}"(id INTEGER)') + + workspace.write_raw_rows( + [{"account_code": "6111", "amount": 100}, {"account_code": "6113", "amount": 200}] + ) + lookup_table = workspace.register_lookup_table( + "lookup_account", [{"account_code": "6111", "category": "租金"}] + ) + workspace.cleanup_run_tables() + + names = _table_names(workspace.sqlite_db_path) + assert "unrelated_table" in names + assert "unrelated_lookup" in names + assert workspace.raw_table not in names + assert workspace.enriched_table not in names + assert lookup_table not in names + + manifest = workspace.manifest() + assert manifest["cleanup_status"] == "deleted" + assert manifest["retention_decision"] == "delete" + + +def test_write_rows_rejects_empty_rows_without_creating_table(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + + with pytest.raises(ValueError, match="without rows"): + workspace.write_raw_rows([]) + + assert workspace.raw_table not in _table_names(workspace.sqlite_db_path) + assert workspace.manifest()["raw_row_count"] == 0 + + +def test_write_raw_rows_preserves_columns_that_appear_after_first_row(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="run-001") + + workspace.write_raw_rows( + [ + {"account_code": "6111"}, + {"account_code": "6113", "department_code": "D001"}, + ] + ) + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + columns = [ + row[1] + for row in conn.execute(f'PRAGMA table_info("{workspace.raw_table}")').fetchall() + ] + rows = conn.execute( + f'SELECT account_code, department_code FROM "{workspace.raw_table}" ORDER BY account_code' + ).fetchall() + + assert columns == ["account_code", "department_code"] + assert rows == [("6111", None), ("6113", "D001")] + assert workspace.manifest()["raw_row_count"] == 2 + + +def test_manifest_file_is_valid_utf8_json(tmp_path: Path): + workspace = SQLiteRunWorkspace.create(tmp_path, run_id="報表 run") + + payload = json.loads(workspace.manifest_path.read_text(encoding="utf-8")) + + assert payload["run_prefix"].startswith("wferp_") + assert "報表" not in payload["run_prefix"] diff --git a/tests/skill_scripts/test_style_replay.py b/tests/skill_scripts/test_style_replay.py new file mode 100644 index 0000000..7546812 --- /dev/null +++ b/tests/skill_scripts/test_style_replay.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from skill_scripts.style_replay import apply_style_capsule +from skill_scripts.style_replay import build_style_capsule +from skill_scripts.style_replay import detect_replay_adjustments + + +def _brief() -> dict: + return { + "catalog_guardrail": "trend-briefing", + "layout_recipe": {"mode": "trend-first", "sections": ["趨勢摘要"]}, + "chart_recipe": [ + { + "id": "period_trend", + "type": "line", + "purpose": "期間趨勢", + "required_columns": ["period"], + } + ], + "table_recipe": [{"id": "period_table", "type": "interactive-detail", "features": ["filter"]}], + "interaction_recipe": {"cross_filter": True}, + "visual_direction": {"density": "balanced", "tone": "趨勢解讀"}, + "embedded_data_policy": {"mode": "smart-tiered"}, + } + + +def test_style_capsule_has_stable_fingerprint(): + first = build_style_capsule(_brief()) + second = build_style_capsule(_brief()) + + assert first["style_fingerprint"] == second["style_fingerprint"] + assert first["catalog_guardrail"] == "trend-briefing" + assert first["style_version"] == "wferp.style-capsule.v1" + + +def test_style_capsule_excludes_query_result_row_count_from_table_recipe(): + first_brief = _brief() + second_brief = _brief() + first_brief["table_recipe"][0]["row_count"] = 25 + second_brief["table_recipe"][0]["row_count"] = 5000 + + first = build_style_capsule(first_brief) + second = build_style_capsule(second_brief) + + assert "row_count" not in first["table_recipe"][0] + assert first["style_fingerprint"] == second["style_fingerprint"] + + +def test_apply_style_capsule_preserves_layout_and_uses_new_prompt(): + capsule = build_style_capsule(_brief()) + replayed = apply_style_capsule(capsule, new_prompt="改查 2027 Q1 行政部費用") + + assert replayed["prompt"] == "改查 2027 Q1 行政部費用" + assert replayed["layout_recipe"] == capsule["layout_recipe"] + assert replayed["style_fingerprint"] == capsule["style_fingerprint"] + + +def test_detect_replay_adjustments_requires_checkpoint_for_missing_columns(): + capsule = build_style_capsule(_brief()) + result = detect_replay_adjustments(capsule, new_columns=["department", "amount"]) + + assert result["requires_checkpoint"] is True + assert result["incompatible_charts"] == ["period_trend"] + assert result["suggested_replacements"][0]["type"] == "bar" + + +def test_detect_replay_adjustments_allows_compatible_columns(): + capsule = build_style_capsule(_brief()) + result = detect_replay_adjustments(capsule, new_columns=["period", "amount"]) + + assert result == { + "requires_checkpoint": False, + "incompatible_charts": [], + "suggested_replacements": [], + } diff --git a/tests/skill_scripts/test_style_replay_e2e.py b/tests/skill_scripts/test_style_replay_e2e.py new file mode 100644 index 0000000..895b535 --- /dev/null +++ b/tests/skill_scripts/test_style_replay_e2e.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from skill_scripts.dynamic_design_brief import build_design_brief +from skill_scripts.style_replay import apply_style_capsule +from skill_scripts.style_replay import build_style_capsule +from skill_scripts.style_replay import detect_replay_adjustments + + +def _package(prompt: str, total_amount: int, columns: list[str]) -> dict: + return { + "package_id": prompt, + "prompt": prompt, + "catalog_guardrail": "financial-control", + "data_profile": {"row_count": 2, "columns": columns}, + "aggregates": { + "total_amount": total_amount, + "total_budget": 100000, + "variance_amount": total_amount - 100000, + }, + } + + +def test_style_replay_generates_new_report_with_same_style_and_new_data(): + first_package = _package("查詢 2026 Q1 費用", 120000, ["department", "amount", "budget_amount"]) + first_brief = build_design_brief(first_package) + capsule = build_style_capsule(first_brief) + + replay = apply_style_capsule(capsule, new_prompt="改查 2027 Q1 行政部費用") + second_package = _package(replay["prompt"], 88000, ["department", "amount", "budget_amount"]) + + assert replay["style_fingerprint"] == capsule["style_fingerprint"] + assert second_package["prompt"] != first_package["prompt"] + assert second_package["aggregates"]["total_amount"] != first_package["aggregates"]["total_amount"] + assert second_package["aggregates"]["variance_amount"] == -12000 + + +def test_style_replay_requires_design_adjustment_checkpoint_when_chart_columns_missing(): + trend_package = _package("查詢月趨勢", 120000, ["period", "amount"]) + trend_brief = build_design_brief(trend_package) + trend_brief["chart_recipe"] = [ + {"id": "period_trend", "type": "line", "purpose": "月趨勢", "required_columns": ["period"]} + ] + capsule = build_style_capsule(trend_brief) + + result = detect_replay_adjustments(capsule, new_columns=["department", "amount"]) + + assert result["requires_checkpoint"] is True + assert result["incompatible_charts"] == ["period_trend"] diff --git a/tests/skill_scripts/test_user_step_payload.py b/tests/skill_scripts/test_user_step_payload.py new file mode 100644 index 0000000..0b1c324 --- /dev/null +++ b/tests/skill_scripts/test_user_step_payload.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from skill_scripts.report_harness import ReportHarness +from skill_scripts.user_step_payload import build_user_step_payload + + +def test_step_1_builds_source_to_output_logic_payload(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="build html and excel report") + harness.write_field_formula_classification( + { + "output_targets": ["html", "excel"], + "source_inventory": [{"kind": "excel_source", "name": "orders.xlsx"}], + "source_to_output_matrix": [ + { + "output_item": "Excel detail sheet", + "source": "DB raw rows + Excel lookup", + "transformation_logic": "join raw rows with lookup table and compute totals", + "processing_layer": "sqlite-enrichment", + "verification": "HTML KPI and Excel totals match", + } + ], + "formula_semantics": [ + { + "name": "total_amount", + "intent": "sum order amounts", + "processing_layer": "sqlite-enrichment", + } + ], + } + ) + + payload = build_user_step_payload(harness.run_dir, 1) + + assert payload["user_step"] == 1 + assert payload["label"] == "source_to_output_logic" + assert payload["output_targets"] == ["html", "excel"] + assert payload["source_to_output_matrix"][0]["output_item"] == "Excel detail sheet" + assert payload["formula_semantics"][0]["processing_layer"] == "sqlite-enrichment" + assert payload["technical_checkpoints"] == ["excel_confirmation", "field_formula_classification"] + + +def test_step_3_uses_real_raw_and_enriched_rows_with_50_row_limit(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="build report") + raw_rows = [{"id": index, "amount": index * 10} for index in range(60)] + enriched_rows = [{"id": index, "amount": index * 10, "category": "A"} for index in range(60)] + harness.update_state( + raw_data_preview={"row_count": 60, "columns": ["id", "amount"], "sample_rows": raw_rows}, + enriched_data_preview={ + "row_count": 60, + "columns": ["id", "amount", "category"], + "sample_rows": enriched_rows, + }, + sqlite_manifest={ + "lookup_tables": ["lookup_customer"], + "lookup_row_counts": {"lookup_customer": 3}, + "ignored_lookup_rows": {}, + }, + ) + + payload = build_user_step_payload(harness.run_dir, 3) + + assert payload["user_step"] == 3 + assert payload["label"] == "data_result_and_report_design" + assert len(payload["raw_preview"]["sample_rows"]) == 50 + assert len(payload["enriched_preview"]["sample_rows"]) == 50 + assert payload["raw_preview"]["total_row_count"] == 60 + assert payload["enriched_preview"]["total_row_count"] == 60 + assert payload["sqlite_summary"]["lookup_tables"] == ["lookup_customer"] + assert payload["data_source"] == "current-run" + assert payload["uses_mock_data"] is False + + +def test_write_user_step_preview_cli_persists_payload(tmp_path: Path): + from skill_scripts.cli_report_harness import main + + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="build report") + result = main(["write-user-step-preview", "--run-dir", str(harness.run_dir), "--step", "1"]) + + assert result == 0 + persisted = json.loads( + (harness.run_dir / "checkpoints" / "user_step_1.json").read_text(encoding="utf-8") + ) + assert persisted["user_step"] == 1 + assert persisted["label"] == "source_to_output_logic" diff --git a/tests/skill_scripts/test_validator_contracts.py b/tests/skill_scripts/test_validator_contracts.py new file mode 100644 index 0000000..16208a7 --- /dev/null +++ b/tests/skill_scripts/test_validator_contracts.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import pytest + +from skill_scripts import validator_contracts +from skill_scripts.validator_contracts import ( + REQUIRED_VALIDATORS, + ValidatorContractError, + build_final_review_gate, + validate_evidence_packet, +) + + +def _with_fresh_reviewer(packet: dict[str, object]) -> dict[str, object]: + role = str(packet.get("role", "validator")) + return { + "reviewer_identity": {"kind": "subagent", "id": f"{role}-agent"}, + "checked_scope": ["run-dir"], + "input_artifact_paths": ["checkpoints/current.json"], + "reviewed_at": "2026-06-20T00:00:00Z", + **packet, + } + + +def test_validator_contracts_include_required_roles(): + assert REQUIRED_VALIDATORS == [ + "source_requirement_reviewer", + "excel_classification_reviewer", + "excel_formula_reviewer", + "schema_relationship_reviewer", + "sql_safety_reviewer", + "sqlite_enrichment_reviewer", + "data_preview_reviewer", + "report_content_reviewer", + "data_visualization_reviewer", + "visual_taste_reviewer", + "react_technical_reviewer", + ] + + +def test_required_validators_include_classification_and_sqlite_enrichment(): + assert "excel_classification_reviewer" in REQUIRED_VALIDATORS + assert "sqlite_enrichment_reviewer" in REQUIRED_VALIDATORS + + +def test_validator_result_requires_evidence_and_repair_fields(): + result = validator_contracts.ValidatorResult( + role="sql_safety_reviewer", + status="fail", + evidence=[{"command": "python3 -m skill_scripts.cli_report_harness validate-sql"}], + findings=["SELECT INTO is blocked"], + requiredFixes=["Remove SELECT INTO"], + residualRisks=[], + ) + + assert result.to_dict() == { + "valid": True, + "role": "sql_safety_reviewer", + "status": "fail", + "reviewer_identity": {"kind": "subagent", "id": "sql_safety_reviewer-agent"}, + "checked_scope": ["run-dir"], + "input_artifact_paths": ["checkpoints/current.json"], + "reviewed_at": "1970-01-01T00:00:00Z", + "evidence": [{"command": "python3 -m skill_scripts.cli_report_harness validate-sql"}], + "findings": ["SELECT INTO is blocked"], + "requiredFixes": ["Remove SELECT INTO"], + "residualRisks": [], + } + + +def test_validator_contract_requires_fresh_reviewer_metadata(): + packet = { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [{"type": "file", "path": "sql/query.sql"}], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + with pytest.raises(ValidatorContractError, match="reviewer_identity"): + validate_evidence_packet(packet) + + +def test_validator_contract_requires_status_evidence_findings_and_repair_fields(): + packet = { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [{"command": "python3 -m skill_scripts.cli_report_harness validate-sql"}], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + packet = _with_fresh_reviewer(packet) + assert validate_evidence_packet(packet) == {"valid": True, **packet} + + for field in ( + "role", + "status", + "reviewer_identity", + "checked_scope", + "input_artifact_paths", + "reviewed_at", + "evidence", + "findings", + "requiredFixes", + "residualRisks", + ): + incomplete = dict(packet) + incomplete.pop(field) + with pytest.raises(ValidatorContractError, match=field): + validate_evidence_packet(incomplete) + + +def test_validator_contract_accepts_structured_evidence_types(): + packet = { + "role": "excel_classification_reviewer", + "status": "pass", + "evidence": [ + {"type": "file", "path": "data/column-classification.json", "detail": "classification payload"}, + {"type": "metric", "name": "classified_columns", "value": 12}, + {"type": "metric", "name": "db_field_count", "value": 7}, + {"type": "metric", "name": "formula_field_count", "value": 3}, + {"type": "metric", "name": "lookup_field_count", "value": 1}, + {"type": "metric", "name": "manual_only_count", "value": 1}, + {"type": "inspection", "name": "metadata_readability", "status": "pass"}, + {"type": "command", "command": "pytest tests/skill_scripts/test_workbook_classifier.py -v"}, + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + packet = _with_fresh_reviewer(packet) + assert validate_evidence_packet(packet) == {"valid": True, **packet} + + +def test_excel_classification_reviewer_requires_file_metrics_and_metadata_inspection(): + packet = { + "role": "excel_classification_reviewer", + "status": "pass", + "evidence": [ + {"type": "file", "path": "data/field-classification.json"}, + {"type": "metric", "name": "classified_columns", "value": 12}, + {"type": "metric", "name": "db_field_count", "value": 7}, + {"type": "metric", "name": "formula_field_count", "value": 3}, + {"type": "metric", "name": "lookup_field_count", "value": 1}, + {"type": "metric", "name": "manual_only_count", "value": 1}, + {"type": "inspection", "name": "metadata_readability", "status": "pass"}, + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + packet = _with_fresh_reviewer(packet) + assert validate_evidence_packet(packet)["valid"] is True + + for evidence_index, expected_message in [ + (0, "classification json file"), + (1, "classified_columns"), + (2, "db_field_count"), + (3, "formula_field_count"), + (4, "lookup_field_count"), + (5, "manual_only_count"), + (6, "metadata_readability"), + ]: + missing = dict(packet) + missing["evidence"] = list(packet["evidence"]) + missing["evidence"].pop(evidence_index) + with pytest.raises(ValidatorContractError, match=expected_message): + validate_evidence_packet(missing) + + +def test_sqlite_enrichment_reviewer_requires_manifest_and_row_counts(): + packet = { + "role": "sqlite_enrichment_reviewer", + "status": "pass", + "evidence": [ + { + "type": "file", + "path": "sqlite/wferp_run_sqlite_manifest.json", + "detail": "manifest with raw/enriched row counts", + }, + {"type": "metric", "name": "raw_row_count", "value": 2}, + {"type": "metric", "name": "enriched_row_count", "value": 2}, + {"type": "metric", "name": "ignored_lookup_rows", "value": 0}, + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + packet = _with_fresh_reviewer(packet) + result = validate_evidence_packet(packet) + + assert result["valid"] is True + + +def test_sqlite_enrichment_metrics_must_be_non_negative_numbers(): + packet = { + "role": "sqlite_enrichment_reviewer", + "status": "pass", + "evidence": [ + {"type": "file", "path": "sqlite/wferp_run_sqlite_manifest.json"}, + {"type": "metric", "name": "raw_row_count", "value": 2}, + {"type": "metric", "name": "enriched_row_count", "value": 2}, + {"type": "metric", "name": "ignored_lookup_rows", "value": 0}, + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + packet = _with_fresh_reviewer(packet) + + for bad_value in ("2", None, False, -1): + invalid = dict(packet) + invalid["evidence"] = list(packet["evidence"]) + invalid["evidence"][1] = {"type": "metric", "name": "raw_row_count", "value": bad_value} + with pytest.raises(ValidatorContractError, match="raw_row_count"): + validate_evidence_packet(invalid) + + +def test_validator_contract_rejects_missing_quantitative_checks_for_data_validator(): + packet = { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [{"name": "preview_shape", "status": "pass", "metrics": {"row_count": 10}}], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + with pytest.raises(ValidatorContractError, match="quantitative"): + validate_evidence_packet(_with_fresh_reviewer(packet)) + + +def test_data_preview_quantitative_checks_require_numeric_metrics(): + packet = { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "name": "preview_shape", + "status": "pass", + "detail": "row_count=10; column_count=4", + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + + with pytest.raises(ValidatorContractError, match="quantitative"): + validate_evidence_packet(_with_fresh_reviewer(packet)) + + packet["evidence"][0]["metrics"] = {"row_count": 10, "column_count": 4} + packet = _with_fresh_reviewer(packet) + assert validate_evidence_packet(packet) == {"valid": True, **packet} + + +def test_report_final_review_requires_all_validators_pass_or_explicit_user_acceptance(): + packets = [] + for validator in REQUIRED_VALIDATORS: + evidence = [ + { + "name": "row_count", + "status": "pass", + "detail": "row_count=10; column_count=4", + "metrics": {"row_count": 10, "column_count": 4}, + } + ] + if validator == "sqlite_enrichment_reviewer": + evidence = [ + {"type": "file", "path": "sqlite/wferp_run_sqlite_manifest.json"}, + {"type": "metric", "name": "raw_row_count", "value": 10}, + {"type": "metric", "name": "enriched_row_count", "value": 10}, + {"type": "metric", "name": "ignored_lookup_rows", "value": 0}, + ] + if validator == "excel_classification_reviewer": + evidence = [ + {"type": "file", "path": "data/field-classification.json"}, + {"type": "metric", "name": "classified_columns", "value": 10}, + {"type": "metric", "name": "db_field_count", "value": 6}, + {"type": "metric", "name": "formula_field_count", "value": 2}, + {"type": "metric", "name": "lookup_field_count", "value": 1}, + {"type": "metric", "name": "manual_only_count", "value": 1}, + {"type": "inspection", "name": "metadata_readability", "status": "pass"}, + ] + packets.append( + _with_fresh_reviewer( + { + "role": validator, + "status": "pass", + "evidence": evidence, + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + ) + ) + + assert build_final_review_gate(packets)["allowed"] is True + + packets[0] = dict( + packets[0], + status="fail", + findings=["來源不完整"], + requiredFixes=["補齊來源檔案"], + residualRisks=["使用者確認來源缺口後仍接受"], + ) + gate = build_final_review_gate(packets) + assert gate["allowed"] is False + assert gate["blocking_validators"] == ["source_requirement_reviewer"] + + gate = build_final_review_gate( + packets, + explicit_user_acceptance=True, + accepted_residual_risks=["source_requirement_reviewer: 使用者確認來源缺口後仍接受"], + ) + assert gate["allowed"] is True + + +def test_report_final_review_blocks_missing_validator_roles(): + packet = _with_fresh_reviewer( + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [{"command": "review source inputs"}], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + } + ) + + gate = build_final_review_gate([packet]) + + assert gate["allowed"] is False + assert "excel_formula_reviewer" in gate["blocking_validators"] diff --git a/tests/skill_scripts/test_visual_checkpoint.py b/tests/skill_scripts/test_visual_checkpoint.py new file mode 100644 index 0000000..e59dc51 --- /dev/null +++ b/tests/skill_scripts/test_visual_checkpoint.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from skill_scripts.dynamic_design_brief import build_design_brief, validate_design_brief +from skill_scripts.report_harness import ReportHarness, ReportHarnessError +from skill_scripts.visual_checkpoint import ( + build_visual_checkpoint_payload, + render_visual_checkpoint_html, +) + + +def _report_package() -> dict[str, object]: + return { + "catalog_guardrail": "financial-control", + "prompt": "分析 2026 年各部門費用", + "report_type": "管理摘要", + "data_profile": { + "columns": ["department", "amount", "month"], + "row_count": 3, + "embedded_mode": "full_rows", + "embedded_rows": 3, + }, + "datasets": { + "columns": ["department", "amount", "month"], + "embedded_rows": [ + {"department": "管理部", "amount": 1200, "month": "2026-01"}, + {"department": "研發部", "amount": 2500, "month": "2026-01"}, + {"department": "業務部", "amount": 1800, "month": "2026-02"}, + ], + }, + "aggregates": { + "amount_sum": 5500, + "amount_avg": 1833.33, + "department_count": 3, + }, + } + + +def _design_brief(package: dict[str, object]) -> dict[str, object]: + brief = build_design_brief(package) + assert validate_design_brief(brief)["valid"] is True + return brief + + +def test_visual_checkpoint_payload_uses_real_labels_and_aggregates(): + package = _report_package() + payload = build_visual_checkpoint_payload(_design_brief(package), package) + + assert payload["title"] == "費用分析視覺設計確認" + assert payload["catalog_guardrail"] == "financial-control" + assert payload["data_profile"]["row_count"] == 3 + assert {"label": "amount_sum", "value": 5500} in payload["kpis"] + assert {"label": "amount_avg", "value": 1833.33} in payload["kpis"] + assert any(chart["purpose"] for chart in payload["charts"]) + assert any(table["columns"] == ["department", "amount", "month"] for table in payload["tables"]) + + +def test_visual_checkpoint_html_is_static_and_contains_design_sections(): + package = _report_package() + payload = build_visual_checkpoint_payload(_design_brief(package), package) + + html = render_visual_checkpoint_html(payload) + + assert "費用分析視覺設計確認" in html + assert "amount_sum" in html + assert payload["charts"][0]["purpose"] in html + assert "視覺方向" in html + assert "互動" in html + assert "fetch(" not in html + assert "new WebSocket" not in html + assert "SQL" not in html + + +def test_report_draft_requires_visual_design_confirmation_after_design_brief(tmp_path: Path): + harness = ReportHarness.create(tmp_path, run_id="run-001", prompt="分析 2026 年各部門費用") + package = _report_package() + brief = _design_brief(package) + + harness.write_report_selection( + {"selected_report_type": "管理摘要", "selected_report_design": "financial-control"} + ) + harness.confirm("report_selection", "產生報告") + harness.write_design_brief(brief) + harness.confirm("design_brief", "確認設計") + + with pytest.raises(ReportHarnessError, match="Visual design must be confirmed"): + harness.write_report_draft({"sections": ["executive-summary"]}) + + payload = build_visual_checkpoint_payload(brief, package) + harness.write_visual_design(payload) + harness.confirm("visual_design", "確認視覺設計") + + checkpoint = harness.write_report_draft({"sections": ["executive-summary"]}) + + assert checkpoint["checkpoint"] == "report_draft" diff --git a/tests/skill_scripts/test_workbook_classifier.py b/tests/skill_scripts/test_workbook_classifier.py new file mode 100644 index 0000000..9774f0b --- /dev/null +++ b/tests/skill_scripts/test_workbook_classifier.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +import pytest + +from skill_scripts.workbook_classifier import classify_workbook + + +REQUIRED_COLUMN_KEYS = { + "excel_column", + "excel_header", + "classification", + "processing_location", + "source_expression", + "lookup_sheet", + "field_metadata", + "lineage_inputs", + "relationship_path", + "schema_candidates", + "confidence", + "reason", + "risks", +} + + +def _column_name(index: int) -> str: + name = "" + while index: + index, remainder = divmod(index - 1, 26) + name = chr(65 + remainder) + name + return name + + +def _sheet_xml(rows: list[list[str | int | tuple[str, str] | None]]) -> str: + rendered_rows = [] + for row_index, row in enumerate(rows, start=1): + cells = [] + for column_index, value in enumerate(row, start=1): + cell_ref = f"{_column_name(column_index)}{row_index}" + if isinstance(value, tuple) and value[0] == "formula": + formula = value[1].lstrip("=") + cells.append(f'{escape(formula)}') + elif value is None: + cells.append(f'') + else: + cells.append( + f'{escape(str(value))}' + ) + rendered_rows.append(f'{"".join(cells)}') + return ( + '' + '' + f'{"".join(rendered_rows)}' + "" + ) + + +def _classification_workbook(path: Path) -> None: + sheets = [ + ( + "明細帳", + [ + [ + "科目編號", + "傳票日期", + "原幣借方金額", + "原幣貸方金額", + "本幣借方金額", + "本幣貸方金額", + "金額-本幣", + "年月", + "金額-原幣", + "BU", + "人工調整", + "原幣餘額", + ], + [ + "6111", + "20260101", + 100, + 0, + 3200, + 0, + ("formula", "=E2-F2"), + ("formula", "=LEFT(B2,6)"), + ("formula", "=C2-D2"), + ("formula", "=VLOOKUP(A2,對照表!A:B,2,0)"), + ("formula", "=Z99+1"), + None, + ], + ], + ), + ( + "對照表", + [ + ["科目編號", "BU"], + ["6111", "營運管理中心"], + ], + ), + ] + workbook_sheets = [] + rels = [] + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + '' + + "".join( + f'' + for index, _ in enumerate(sheets, start=1) + ) + + "", + ) + archive.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + for index, (name, rows) in enumerate(sheets, start=1): + workbook_sheets.append( + f'' + ) + rels.append( + f'' + ) + archive.writestr(f"xl/worksheets/sheet{index}.xml", _sheet_xml(rows)) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'{"".join(workbook_sheets)}' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + f'{"".join(rels)}' + "", + ) + + +def test_classify_workbook_rule_fallback_is_removed(tmp_path: Path): + workbook = tmp_path / "req.xlsx" + _classification_workbook(workbook) + + with pytest.raises(RuntimeError, match="RULE_FALLBACK_REMOVED"): + classify_workbook(workbook, source_dir="_Source", primary_sheet="明細帳") + + +def test_every_column_has_confidence_reason_and_processing_location(tmp_path: Path): + workbook = tmp_path / "req.xlsx" + _classification_workbook(workbook) + + with pytest.raises(RuntimeError, match="RULE_FALLBACK_REMOVED"): + classify_workbook(workbook, source_dir="_Source", primary_sheet="明細帳") diff --git a/tests/skill_scripts/test_workbook_lookup_importer.py b/tests/skill_scripts/test_workbook_lookup_importer.py new file mode 100644 index 0000000..d63d85d --- /dev/null +++ b/tests/skill_scripts/test_workbook_lookup_importer.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import hashlib +import sqlite3 +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +from skill_scripts.sqlite_workspace import SQLiteRunWorkspace +from skill_scripts.workbook_lookup_importer import import_lookup_sheet + + +def _column_name(index: int) -> str: + name = "" + while index: + index, remainder = divmod(index - 1, 26) + name = chr(65 + remainder) + name + return name + + +def _sheet_xml(rows: list[list[str | int | None]]) -> str: + rendered_rows = [] + for row_index, row in enumerate(rows, start=1): + cells = [] + for column_index, value in enumerate(row, start=1): + cell_ref = f"{_column_name(column_index)}{row_index}" + text = "" if value is None else str(value) + cells.append( + f'{escape(text)}' + ) + rendered_rows.append(f'{"".join(cells)}') + return ( + '' + '' + f'{"".join(rendered_rows)}' + "" + ) + + +def _lookup_workbook(path: Path) -> None: + sheets = [ + ( + "明細帳", + [ + ["科目編號", "金額"], + ["6111", 100], + ], + ), + ( + "對照表", + [ + ["科目編號", "費用類別", "費用類別 (群組)"], + ["公司別", "AIS", "0"], + ["本月匯率", "31.5", ""], + ["6111", "8.租金支出", "8.租金支出"], + ["6112", None, None], + ["6113", "9001.旅費", "9001.旅費"], + [None, None, None], + ["加總 - 換算台幣", None, None], + ["加總 6111", None, None], + ], + ), + ] + + workbook_sheets = [] + rels = [] + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + '' + + "".join( + f'' + for index, _ in enumerate(sheets, start=1) + ) + + "", + ) + archive.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + for index, (name, rows) in enumerate(sheets, start=1): + workbook_sheets.append( + f'' + ) + rels.append( + f'' + ) + archive.writestr(f"xl/worksheets/sheet{index}.xml", _sheet_xml(rows)) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'{"".join(workbook_sheets)}' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + f'{"".join(rels)}' + "", + ) + + +def test_import_lookup_sheet_ignores_headers_metadata_and_blank_values(tmp_path: Path): + workbook = tmp_path / "lookup.xlsx" + _lookup_workbook(workbook) + workspace = SQLiteRunWorkspace.create(tmp_path / "run-001", run_id="run-001") + + result = import_lookup_sheet( + workbook, + workspace, + sheet_name="對照表", + logical_name="lookup_account_category", + key_column="A", + value_columns={"expense_category": "B", "expense_group": "C"}, + ) + + assert result["imported_row_count"] == 2 + assert result["ignored_row_count"] == 7 + assert [row["reason"] for row in result["ignored_rows"]].count("blank_values") == 1 + assert result["ignored_rows"][0] == { + "row_number": 1, + "key": "科目編號", + "reason": "header_or_metadata", + } + + with sqlite3.connect(workspace.sqlite_db_path) as conn: + rows = conn.execute( + f'SELECT account_code, expense_category, expense_group FROM "{result["table_name"]}" ORDER BY account_code' + ).fetchall() + assert rows == [ + ("6111", "8.租金支出", "8.租金支出"), + ("6113", "9001.旅費", "9001.旅費"), + ] + + manifest = workspace.manifest() + assert manifest["lookup_tables"] == [result["table_name"]] + assert manifest["lookup_row_counts"] == {result["table_name"]: 2} + assert manifest["ignored_lookup_rows"][result["table_name"]] == result["ignored_rows"] + assert manifest["source_workbook"] == str(workbook) + assert manifest["source_workbook_hash"] == hashlib.sha256(workbook.read_bytes()).hexdigest() + assert manifest["raw_table"] == workspace.raw_table + assert manifest["formal_db_query_hash"] is None diff --git a/tests/source/test_schema_db_config.py b/tests/source/test_schema_db_config.py new file mode 100644 index 0000000..e93c6dd --- /dev/null +++ b/tests/source/test_schema_db_config.py @@ -0,0 +1,94 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +MODULE_PATH = Path(__file__).resolve().parents[2] / "_Source" / "schema_db_config.py" + + +def load_schema_db_config_module(): + spec = importlib.util.spec_from_file_location("schema_db_config_under_test", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_schema_db_config_reads_required_env(): + module = load_schema_db_config_module() + + config = module.SchemaDbConfig.from_env( + { + "WFERP_SCHEMA_DB_HOST": "db.example.com", + "WFERP_SCHEMA_DB_PORT": "1500", + "WFERP_SCHEMA_DB_DATABASE": "ERP_SYS", + "WFERP_SCHEMA_DB_USERNAME": "schema_user", + "WFERP_SCHEMA_DB_PASSWORD": "secret-password", + } + ) + + assert config.host == "db.example.com" + assert config.port == 1500 + assert config.database == "ERP_SYS" + assert config.username == "schema_user" + assert config.password == "secret-password" + + +def test_schema_db_config_defaults_port_and_database(): + module = load_schema_db_config_module() + + config = module.SchemaDbConfig.from_env( + { + "WFERP_SCHEMA_DB_HOST": "db.example.com", + "WFERP_SCHEMA_DB_USERNAME": "schema_user", + "WFERP_SCHEMA_DB_PASSWORD": "secret-password", + } + ) + + assert config.port == 1433 + assert config.database == "DSCSYS" + + +@pytest.mark.parametrize( + "missing_name", + [ + "WFERP_SCHEMA_DB_HOST", + "WFERP_SCHEMA_DB_USERNAME", + "WFERP_SCHEMA_DB_PASSWORD", + ], +) +def test_schema_db_config_rejects_missing_host_username_password(missing_name): + module = load_schema_db_config_module() + environ = { + "WFERP_SCHEMA_DB_HOST": "db.example.com", + "WFERP_SCHEMA_DB_USERNAME": "schema_user", + "WFERP_SCHEMA_DB_PASSWORD": "secret-password", + } + environ.pop(missing_name) + + with pytest.raises(module.SchemaDbConfigError) as excinfo: + module.SchemaDbConfig.from_env(environ) + + assert missing_name in str(excinfo.value) + + +def test_schema_db_config_redacts_password_for_logging(): + module = load_schema_db_config_module() + + config = module.SchemaDbConfig.from_env( + { + "WFERP_SCHEMA_DB_HOST": "db.example.com", + "WFERP_SCHEMA_DB_USERNAME": "schema_user", + "WFERP_SCHEMA_DB_PASSWORD": "secret-password", + } + ) + + assert config.redacted() == { + "host": "db.example.com", + "port": 1433, + "database": "DSCSYS", + "username": "schema_user", + "password": "***", + } diff --git a/wferp-report-runs/expense-analysis-harness-20260619/audit/cli-commands.log b/wferp-report-runs/expense-analysis-harness-20260619/audit/cli-commands.log new file mode 100644 index 0000000..05b9455 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/audit/cli-commands.log @@ -0,0 +1,2828 @@ +$ python3 -m skill_scripts.cli_report_harness create-run --run-root /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs --run-id expense-analysis-harness-20260619 --prompt 費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。 +exit=0 +stdout: +{ + "status": "created", + "run_dir": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619", + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": null, + "sql_validation": null, + "execution_result_summary": null, + "report_type": null, + "report_design": null, + "report_design_brief": null, + "visual_design_checkpoint": null, + "report_options": {}, + "validator_results": [], + "user_confirmations": {}, + "checkpoints": [], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:46:58.810532+00:00" + } +} + +$ python3 -m skill_scripts.cli_report_harness write-excel-confirmation --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --payload /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/inputs/field-formula-payload.json +exit=0 +stdout: +{ + "checkpoint": "excel_confirmation", + "title": "確認欄位與公式", + "actions": [ + "確認欄位與公式", + "要求修正" + ], + "payload": { + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-field-formula", + "title": "費用分析欄位與公式確認", + "step": "excel_confirmation", + "requirementSummary": "以本地 SQLite 測試資料模擬費用分析需求,欄位包含部門、科目、金額、預算、差異與費用占比。", + "fieldFormulaReview": { + "fields": [ + { + "label": "部門代號", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_code", + "confirmation": "由費用測試資料表輸出。" + }, + { + "label": "部門名稱", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_name", + "confirmation": "用於管理報表分組與圖表標籤。" + }, + { + "label": "費用科目", + "source": "EXPENSE_ANALYSIS_FIXTURE.expense_subject", + "confirmation": "列示費用明細科目。" + }, + { + "label": "金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.amount", + "confirmation": "費用實際發生金額。" + }, + { + "label": "預算金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.budget_amount", + "confirmation": "用於差異分析。" + } + ], + "formulas": [ + { + "label": "差異金額", + "expression": "amount - budget_amount", + "confirmation": "正數代表超支。" + }, + { + "label": "費用占比", + "expression": "amount / 2026 expense budget total", + "confirmation": "以總預算 100000 作分母,最大占比預期 0.35。" + } + ] + }, + "dataPreview": { + "rowCount": 0, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [] + }, + "aggregateChecks": [], + "exceptions": [ + "本案例使用 SQLite 本地 DB 測試資料,不連線正式 DB。" + ], + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "來源、欄位與公式已確認為測試案例範圍。" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "衍生公式可由 SQL 欄位計算。" + } + ], + "actions": [ + "確認欄位與公式", + "要求修正" + ] + }, + "created_at": "2026-06-18T23:46:59.396754+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint excel_confirmation --action 確認欄位與公式 --comment 測試案例自動確認欄位與公式。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "excel_confirmation", + "action": "確認欄位與公式", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": null, + "sql_validation": null, + "execution_result_summary": null, + "report_type": null, + "report_design": null, + "report_design_brief": null, + "visual_design_checkpoint": null, + "report_options": {}, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:46:59.877878+00:00", + "user_confirmation_options": { + "excel_confirmation": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-sql-review --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --sql SELECT + [department_code] AS department_code, + [department_name] AS department_name, + [expense_subject] AS expense_subject, + [amount] AS amount, + [budget_amount] AS budget_amount, + [amount] - [budget_amount] AS variance_amount, + CAST([amount] AS REAL) / NULLIF(( + SELECT SUM([budget_amount]) + FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] + WHERE [year] = 2026 AND [account_type] = 'expense' + ), 0) AS expense_ratio +FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] +WHERE [year] = 2026 AND [account_type] = 'expense' +ORDER BY [department_code], [expense_subject] --validation {"readonly": true, "blockedKeywords": [], "executionStatus": "not_executed", "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements."} +exit=0 +stdout: +{ + "checkpoint": "sql_review", + "title": "SQL 查詢確認", + "actions": [ + "同意查詢", + "調整需求" + ], + "payload": { + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } + }, + "created_at": "2026-06-18T23:47:00.383655+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint sql_review --action 同意查詢 --comment SQL 安全檢查通過,允許在本地 SQLite 測試 DB 執行。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "sql_review", + "action": "同意查詢", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": null, + "report_type": null, + "report_design": null, + "report_design_brief": null, + "visual_design_checkpoint": null, + "report_options": {}, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:00.894565+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-data-preview --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --payload /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/data/data-preview-payload.json +exit=0 +stdout: +{ + "checkpoint": "data_preview", + "title": "資料預覽確認", + "actions": [ + "資料正確", + "確認資料", + "重新查詢" + ], + "payload": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "created_at": "2026-06-18T23:47:01.410077+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint data_preview --action 資料正確 --comment row_count 與 aggregates 符合測試驗收標準。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "data_preview", + "action": "資料正確", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": null, + "report_design": null, + "report_design_brief": null, + "visual_design_checkpoint": null, + "report_options": {}, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:01.911960+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-report-selection --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --payload /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/reports/report-selection-payload.json --report-type financial-control --report-design financial-control --include-chart --include-table --include-analysis --include-recommendations +exit=0 +stdout: +{ + "checkpoint": "report_selection", + "title": "報表格式選擇", + "actions": [ + "產生報告", + "修改格式" + ], + "payload": { + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-report-selection", + "title": "費用分析報表格式選擇", + "step": "report_selection", + "requirementSummary": "選擇 financial-control 報告格式,包含圖表、表格、分析與建議。", + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregateChecks": [ + { + "label": "費用筆數", + "expected": 6, + "actual": 6, + "status": "pass" + }, + { + "label": "總金額", + "expected": 120000, + "actual": 120000, + "status": "pass" + }, + { + "label": "總預算", + "expected": 100000, + "actual": 100000, + "status": "pass" + }, + { + "label": "差異金額", + "expected": 20000, + "actual": 20000, + "status": "pass" + } + ], + "reportTypes": [ + { + "id": "financial-control", + "label": "財務控管", + "description": "呈現費用、預算差異與例外追蹤。" + }, + { + "id": "executive-summary", + "label": "主管摘要", + "description": "適合高階快速檢視。" + }, + { + "id": "trend-briefing", + "label": "趨勢簡報", + "description": "適合期間比較與趨勢。" + } + ], + "exceptions": [ + "排除 2025 費用 1 筆;排除 2026 非費用科目 1 筆。" + ], + "validatorEvidence": [ + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "報告選型符合費用分析目標。" + } + ], + "actions": [ + "產生報告", + "修改格式" + ], + "selected_report_type": "financial-control", + "selected_report_design": "financial-control", + "selected_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + } + }, + "created_at": "2026-06-18T23:47:02.452321+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint report_selection --action 產生報告 --selected-option reportType=financial-control --comment 採 financial-control,含圖表、表格、分析與建議。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "report_selection", + "action": "產生報告", + "selectedOptions": { + "reportType": "financial-control" + }, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": null, + "visual_design_checkpoint": null, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:02.943550+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + } + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-design-brief --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --package /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package-for-design.json +exit=0 +stdout: +{ + "checkpoint": "design_brief", + "title": "動態設計確認", + "actions": [ + "確認設計", + "調整設計" + ], + "payload": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "created_at": "2026-06-18T23:47:03.481659+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint design_brief --action 確認設計 --comment 確認 kpi-first-dashboard、費用趨勢圖、明細表與 evidence drawer。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "design_brief", + "action": "確認設計", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "visual_design_checkpoint": null, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告", + "design_brief": "確認設計" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + }, + { + "checkpoint": "design_brief", + "file": "checkpoints/04a_design_brief.json", + "created_at": "2026-06-18T23:47:03.481659+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:03.964317+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + }, + "design_brief": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-visual-checkpoint --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --brief /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-design-brief.json --package /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package-for-design.json +exit=0 +stdout: +{ + "checkpoint": "visual_design", + "title": "視覺設計確認", + "actions": [ + "確認視覺設計", + "調整視覺設計" + ], + "payload": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "created_at": "2026-06-18T23:47:04.480122+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint visual_design --action 確認視覺設計 --comment 視覺 checkpoint 使用真 labels 與 aggregates。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "visual_design", + "action": "確認視覺設計", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "visual_design_checkpoint": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告", + "design_brief": "確認設計", + "visual_design": "確認視覺設計" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + }, + { + "checkpoint": "design_brief", + "file": "checkpoints/04a_design_brief.json", + "created_at": "2026-06-18T23:47:03.481659+00:00" + }, + { + "checkpoint": "visual_design", + "file": "checkpoints/04b_visual_design.json", + "created_at": "2026-06-18T23:47:04.480122+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:04.981782+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + }, + "design_brief": {}, + "visual_design": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-report-draft --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --payload /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/reports/report-render-payload.json +exit=0 +stdout: +{ + "checkpoint": "report_draft", + "title": "報告初稿審核", + "actions": [ + "接受", + "修正報告" + ], + "payload": { + "kind": "report", + "title": "2026 費用分析測試報告", + "subtitle": "SQLite 本地 DB 真實 SQL 執行驗證,financial-control 樣式", + "options": { + "charts": true, + "tables": true, + "analysis": true, + "recommendations": true + }, + "sections": [ + { + "type": "analysis", + "title": "管理摘要", + "body": "2026 年費用共 6 筆,總金額 120,000,總預算 100,000,超支 20,000。行政部旅費單項占比最高,max_expense_ratio=0.35。" + }, + { + "type": "analysis", + "title": "例外排除", + "body": "排除非 2026 年費用 1 筆,排除 2026 非費用科目 1 筆,排除規則已由 SQL WHERE 條件落實。" + }, + { + "type": "recommendations", + "title": "建議事項", + "items": [ + "針對行政部旅費與研發部雲端服務建立月度預算差異追蹤。", + "保留 query.sql、SQLite execution result、validator evidence 供審計追溯。" + ] + } + ], + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "sql_safety_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "schema_relationship_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_preview_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "visual_taste_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_visualization_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "react_technical_reviewer", + "status": "pass", + "message": "pass" + } + ] + }, + "created_at": "2026-06-18T23:47:05.487094+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint report_draft --action 接受 --comment 初稿內容、圖表、表格與 evidence drawer 可接受。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "report_draft", + "action": "接受", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "visual_design_checkpoint": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告", + "design_brief": "確認設計", + "visual_design": "確認視覺設計", + "report_draft": "接受" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + }, + { + "checkpoint": "design_brief", + "file": "checkpoints/04a_design_brief.json", + "created_at": "2026-06-18T23:47:03.481659+00:00" + }, + { + "checkpoint": "visual_design", + "file": "checkpoints/04b_visual_design.json", + "created_at": "2026-06-18T23:47:04.480122+00:00" + }, + { + "checkpoint": "report_draft", + "file": "checkpoints/05_report_draft.json", + "created_at": "2026-06-18T23:47:05.487094+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:05.981631+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + }, + "design_brief": {}, + "visual_design": {}, + "report_draft": {} + } + } +} + +$ python3 -m skill_scripts.cli_report_harness write-final-review --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --payload /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/review/final-review-payload.json +exit=0 +stdout: +{ + "checkpoint": "final_review", + "title": "最終報告審核", + "actions": [ + "完成", + "回到初稿" + ], + "payload": { + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ] + }, + "created_at": "2026-06-18T23:47:06.480467+00:00" +} + +$ python3 -m skill_scripts.cli_report_harness confirm --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --checkpoint final_review --action 完成 --comment 全部 validators pass,無 residual risk。 +exit=0 +stdout: +{ + "status": "confirmed", + "checkpoint": "final_review", + "action": "完成", + "selectedOptions": {}, + "state": { + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "visual_design_checkpoint": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告", + "design_brief": "確認設計", + "visual_design": "確認視覺設計", + "report_draft": "接受", + "final_review": "完成" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + }, + { + "checkpoint": "design_brief", + "file": "checkpoints/04a_design_brief.json", + "created_at": "2026-06-18T23:47:03.481659+00:00" + }, + { + "checkpoint": "visual_design", + "file": "checkpoints/04b_visual_design.json", + "created_at": "2026-06-18T23:47:04.480122+00:00" + }, + { + "checkpoint": "report_draft", + "file": "checkpoints/05_report_draft.json", + "created_at": "2026-06-18T23:47:05.487094+00:00" + }, + { + "checkpoint": "final_review", + "file": "checkpoints/06_final_review.json", + "created_at": "2026-06-18T23:47:06.480467+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:06.978348+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + }, + "design_brief": {}, + "visual_design": {}, + "report_draft": {}, + "final_review": {} + }, + "accepted_residual_risks": [] + } +} + +$ python3 -m skill_scripts.cli_report_harness can-deliver --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 +exit=0 +stdout: +{ + "status": "ok", + "allowed": true, + "blocking_validators": [], + "accepted_residual_risks": [] +} + +$ python3 -m skill_scripts.cli_report_harness export-single-html --run-dir /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619 --package /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package.json --brief /home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-design-brief.json +exit=0 +stdout: +{ + "status": "exported", + "html_path": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html", + "html_sha256": "9bb4d82b522c691cd4f83f515ae015197dd5328d4dcf381cf7121be3b5d11c65" +} + diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.confirmation.json new file mode 100644 index 0000000..1d27a58 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "excel_confirmation", + "action": "確認欄位與公式", + "comment": "測試案例自動確認欄位與公式。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:46:59.879527+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.json new file mode 100644 index 0000000..a503e7c --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/01_excel_confirmation.json @@ -0,0 +1,90 @@ +{ + "checkpoint": "excel_confirmation", + "title": "確認欄位與公式", + "actions": [ + "確認欄位與公式", + "要求修正" + ], + "payload": { + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-field-formula", + "title": "費用分析欄位與公式確認", + "step": "excel_confirmation", + "requirementSummary": "以本地 SQLite 測試資料模擬費用分析需求,欄位包含部門、科目、金額、預算、差異與費用占比。", + "fieldFormulaReview": { + "fields": [ + { + "label": "部門代號", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_code", + "confirmation": "由費用測試資料表輸出。" + }, + { + "label": "部門名稱", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_name", + "confirmation": "用於管理報表分組與圖表標籤。" + }, + { + "label": "費用科目", + "source": "EXPENSE_ANALYSIS_FIXTURE.expense_subject", + "confirmation": "列示費用明細科目。" + }, + { + "label": "金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.amount", + "confirmation": "費用實際發生金額。" + }, + { + "label": "預算金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.budget_amount", + "confirmation": "用於差異分析。" + } + ], + "formulas": [ + { + "label": "差異金額", + "expression": "amount - budget_amount", + "confirmation": "正數代表超支。" + }, + { + "label": "費用占比", + "expression": "amount / 2026 expense budget total", + "confirmation": "以總預算 100000 作分母,最大占比預期 0.35。" + } + ] + }, + "dataPreview": { + "rowCount": 0, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [] + }, + "aggregateChecks": [], + "exceptions": [ + "本案例使用 SQLite 本地 DB 測試資料,不連線正式 DB。" + ], + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "來源、欄位與公式已確認為測試案例範圍。" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "衍生公式可由 SQL 欄位計算。" + } + ], + "actions": [ + "確認欄位與公式", + "要求修正" + ] + }, + "created_at": "2026-06-18T23:46:59.396754+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.confirmation.json new file mode 100644 index 0000000..3edf276 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "sql_review", + "action": "同意查詢", + "comment": "SQL 安全檢查通過,允許在本地 SQLite 測試 DB 執行。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:00.896208+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.json new file mode 100644 index 0000000..7a8a826 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/02_sql_review.json @@ -0,0 +1,18 @@ +{ + "checkpoint": "sql_review", + "title": "SQL 查詢確認", + "actions": [ + "同意查詢", + "調整需求" + ], + "payload": { + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } + }, + "created_at": "2026-06-18T23:47:00.383655+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.confirmation.json new file mode 100644 index 0000000..4cf2830 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "data_preview", + "action": "資料正確", + "comment": "row_count 與 aggregates 符合測試驗收標準。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:01.913854+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.json new file mode 100644 index 0000000..56e10ef --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/03_data_preview.json @@ -0,0 +1,93 @@ +{ + "checkpoint": "data_preview", + "title": "資料預覽確認", + "actions": [ + "資料正確", + "確認資料", + "重新查詢" + ], + "payload": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "created_at": "2026-06-18T23:47:01.410077+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.confirmation.json new file mode 100644 index 0000000..4358f8b --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.confirmation.json @@ -0,0 +1,9 @@ +{ + "checkpoint": "report_selection", + "action": "產生報告", + "comment": "採 financial-control,含圖表、表格、分析與建議。", + "selectedOptions": { + "reportType": "financial-control" + }, + "created_at": "2026-06-18T23:47:02.945335+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.json new file mode 100644 index 0000000..dd5822d --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04_report_selection.json @@ -0,0 +1,149 @@ +{ + "checkpoint": "report_selection", + "title": "報表格式選擇", + "actions": [ + "產生報告", + "修改格式" + ], + "payload": { + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-report-selection", + "title": "費用分析報表格式選擇", + "step": "report_selection", + "requirementSummary": "選擇 financial-control 報告格式,包含圖表、表格、分析與建議。", + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregateChecks": [ + { + "label": "費用筆數", + "expected": 6, + "actual": 6, + "status": "pass" + }, + { + "label": "總金額", + "expected": 120000, + "actual": 120000, + "status": "pass" + }, + { + "label": "總預算", + "expected": 100000, + "actual": 100000, + "status": "pass" + }, + { + "label": "差異金額", + "expected": 20000, + "actual": 20000, + "status": "pass" + } + ], + "reportTypes": [ + { + "id": "financial-control", + "label": "財務控管", + "description": "呈現費用、預算差異與例外追蹤。" + }, + { + "id": "executive-summary", + "label": "主管摘要", + "description": "適合高階快速檢視。" + }, + { + "id": "trend-briefing", + "label": "趨勢簡報", + "description": "適合期間比較與趨勢。" + } + ], + "exceptions": [ + "排除 2025 費用 1 筆;排除 2026 非費用科目 1 筆。" + ], + "validatorEvidence": [ + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "報告選型符合費用分析目標。" + } + ], + "actions": [ + "產生報告", + "修改格式" + ], + "selected_report_type": "financial-control", + "selected_report_design": "financial-control", + "selected_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + } + }, + "created_at": "2026-06-18T23:47:02.452321+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.confirmation.json new file mode 100644 index 0000000..eb7eeea --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "design_brief", + "action": "確認設計", + "comment": "確認 kpi-first-dashboard、費用趨勢圖、明細表與 evidence drawer。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:03.966481+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.json new file mode 100644 index 0000000..8f9dd84 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04a_design_brief.json @@ -0,0 +1,95 @@ +{ + "checkpoint": "design_brief", + "title": "動態設計確認", + "actions": [ + "確認設計", + "調整設計" + ], + "payload": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "created_at": "2026-06-18T23:47:03.481659+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.confirmation.json new file mode 100644 index 0000000..283f7e4 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "visual_design", + "action": "確認視覺設計", + "comment": "視覺 checkpoint 使用真 labels 與 aggregates。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:04.984290+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.json new file mode 100644 index 0000000..6e15fea --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/04b_visual_design.json @@ -0,0 +1,120 @@ +{ + "checkpoint": "visual_design", + "title": "視覺設計確認", + "actions": [ + "確認視覺設計", + "調整視覺設計" + ], + "payload": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "created_at": "2026-06-18T23:47:04.480122+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.confirmation.json new file mode 100644 index 0000000..c063b8f --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "report_draft", + "action": "接受", + "comment": "初稿內容、圖表、表格與 evidence drawer 可接受。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:05.983682+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.json new file mode 100644 index 0000000..9b1c0c5 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/05_report_draft.json @@ -0,0 +1,155 @@ +{ + "checkpoint": "report_draft", + "title": "報告初稿審核", + "actions": [ + "接受", + "修正報告" + ], + "payload": { + "kind": "report", + "title": "2026 費用分析測試報告", + "subtitle": "SQLite 本地 DB 真實 SQL 執行驗證,financial-control 樣式", + "options": { + "charts": true, + "tables": true, + "analysis": true, + "recommendations": true + }, + "sections": [ + { + "type": "analysis", + "title": "管理摘要", + "body": "2026 年費用共 6 筆,總金額 120,000,總預算 100,000,超支 20,000。行政部旅費單項占比最高,max_expense_ratio=0.35。" + }, + { + "type": "analysis", + "title": "例外排除", + "body": "排除非 2026 年費用 1 筆,排除 2026 非費用科目 1 筆,排除規則已由 SQL WHERE 條件落實。" + }, + { + "type": "recommendations", + "title": "建議事項", + "items": [ + "針對行政部旅費與研發部雲端服務建立月度預算差異追蹤。", + "保留 query.sql、SQLite execution result、validator evidence 供審計追溯。" + ] + } + ], + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "sql_safety_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "schema_relationship_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_preview_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "visual_taste_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_visualization_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "react_technical_reviewer", + "status": "pass", + "message": "pass" + } + ] + }, + "created_at": "2026-06-18T23:47:05.487094+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.confirmation.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.confirmation.json new file mode 100644 index 0000000..b508f56 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.confirmation.json @@ -0,0 +1,7 @@ +{ + "checkpoint": "final_review", + "action": "完成", + "comment": "全部 validators pass,無 residual risk。", + "selectedOptions": {}, + "created_at": "2026-06-18T23:47:06.980709+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.json b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.json new file mode 100644 index 0000000..e2d4aac --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/checkpoints/06_final_review.json @@ -0,0 +1,134 @@ +{ + "checkpoint": "final_review", + "title": "最終報告審核", + "actions": [ + "完成", + "回到初稿" + ], + "payload": { + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ] + }, + "created_at": "2026-06-18T23:47:06.480467+00:00" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/data/data-preview-payload.json b/wferp-report-runs/expense-analysis-harness-20260619/data/data-preview-payload.json new file mode 100644 index 0000000..442180d --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/data/data-preview-payload.json @@ -0,0 +1,83 @@ +{ + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/data/execution-result.json b/wferp-report-runs/expense-analysis-harness-20260619/data/execution-result.json new file mode 100644 index 0000000..ce435c6 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/data/execution-result.json @@ -0,0 +1,83 @@ +{ + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/data/sqlite-expense-result.json b/wferp-report-runs/expense-analysis-harness-20260619/data/sqlite-expense-result.json new file mode 100644 index 0000000..c0e9f13 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/data/sqlite-expense-result.json @@ -0,0 +1,83 @@ +{ + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "sql_safety": { + "readonly": true, + "blocked_keywords": [] + }, + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/data/wferp_expense.sqlite3 b/wferp-report-runs/expense-analysis-harness-20260619/data/wferp_expense.sqlite3 new file mode 100644 index 0000000..bac69b5 Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/data/wferp_expense.sqlite3 differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/delivery-manifest.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/delivery-manifest.json new file mode 100644 index 0000000..210a7aa --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/delivery-manifest.json @@ -0,0 +1,9 @@ +{ + "catalog_guardrail": "financial-control", + "html_path": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html", + "html_sha256": "9bb4d82b522c691cd4f83f515ae015197dd5328d4dcf381cf7121be3b5d11c65", + "package_sha256": "43cbe62382ef32cca00caffdeada43712dba4a28a4ceaf46df68246eb47f856a", + "row_count": 6, + "style_fingerprint": "4e9b070aadd45a851c859992bb6f17c7379f04dedaf09e16c23f8384e5607af7", + "validator_status": "pass" +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/query.sql b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/query.sql new file mode 100644 index 0000000..d44fb7a --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/query.sql @@ -0,0 +1,15 @@ +SELECT + [department_code] AS department_code, + [department_name] AS department_name, + [expense_subject] AS expense_subject, + [amount] AS amount, + [budget_amount] AS budget_amount, + [amount] - [budget_amount] AS variance_amount, + CAST([amount] AS REAL) / NULLIF(( + SELECT SUM([budget_amount]) + FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] + WHERE [year] = 2026 AND [account_type] = 'expense' + ), 0) AS expense_ratio +FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] +WHERE [year] = 2026 AND [account_type] = 'expense' +ORDER BY [department_code], [expense_subject] diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-design-brief.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-design-brief.json new file mode 100644 index 0000000..b10d316 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-design-brief.json @@ -0,0 +1,86 @@ +{ + "catalog_guardrail": "financial-control", + "chart_recipe": [ + { + "id": "expense-budget-variance", + "purpose": "Compare actual expense, budget, and variance in one control view.", + "type": "combo" + }, + { + "id": "expense-composition", + "purpose": "Show expense composition across departments or categories.", + "type": "stacked-bar" + }, + { + "id": "expense-driver-ranking", + "purpose": "Rank the largest expense drivers for exception review.", + "type": "bar" + } + ], + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + }, + "interaction_recipe": { + "drilldowns": [ + "department", + "category", + "document" + ], + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ] + }, + "layout_recipe": { + "density": "analysis-first", + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ] + }, + "report_intent": { + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals.", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control" + }, + "schema_version": "wferp.design-brief.v1", + "table_recipe": [ + { + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "id": "expense-detail-table", + "row_count": 6, + "type": "data-table" + } + ], + "target_audience": { + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ], + "role": "finance controller" + }, + "visual_direction": { + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ], + "tone": "quiet financial operations dashboard" + } +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-package.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-package.json new file mode 100644 index 0000000..ae2e168 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-package.json @@ -0,0 +1,265 @@ +{ + "accepted_residual_risks": [], + "aggregates": { + "max_expense_ratio": 0.35, + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000 + }, + "catalog_guardrail": "financial-control", + "data_profile": { + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false, + "row_count": 6 + }, + "datasets": { + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_rows": [ + { + "amount": 10000, + "budget_amount": 8000, + "department_code": "ADM", + "department_name": "行政部", + "expense_ratio": 0.1, + "expense_subject": "文具", + "variance_amount": 2000 + }, + { + "amount": 35000, + "budget_amount": 30000, + "department_code": "ADM", + "department_name": "行政部", + "expense_ratio": 0.35, + "expense_subject": "旅費", + "variance_amount": 5000 + }, + { + "amount": 25000, + "budget_amount": 22000, + "department_code": "RND", + "department_name": "研發部", + "expense_ratio": 0.25, + "expense_subject": "軟體訂閱", + "variance_amount": 3000 + }, + { + "amount": 30000, + "budget_amount": 25000, + "department_code": "RND", + "department_name": "研發部", + "expense_ratio": 0.3, + "expense_subject": "雲端服務", + "variance_amount": 5000 + }, + { + "amount": 12000, + "budget_amount": 10000, + "department_code": "SAL", + "department_name": "業務部", + "expense_ratio": 0.12, + "expense_subject": "交際費", + "variance_amount": 2000 + }, + { + "amount": 8000, + "budget_amount": 5000, + "department_code": "SAL", + "department_name": "業務部", + "expense_ratio": 0.08, + "expense_subject": "廣告費", + "variance_amount": 3000 + } + ] + }, + "delivery_gate": { + "accepted_residual_risks": [], + "allowed": true, + "blocking_validators": [] + }, + "evidence_index": [ + { + "id": "sql", + "path": "evidence/query.sql" + }, + { + "id": "execution_result", + "path": "evidence/execution-result.json" + }, + { + "id": "validators", + "path": "evidence/validator-results.json" + } + ], + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "hashes": { + "package_sha256": "43cbe62382ef32cca00caffdeada43712dba4a28a4ceaf46df68246eb47f856a", + "sql_sha256": "fbeb6f14f0400fa9119389af41a62fe160eacfdab5954671ad5f9946f8e2e8f6" + }, + "package_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_options": { + "include_analysis": true, + "include_chart": true, + "include_recommendations": true, + "include_table": true + }, + "report_type": "financial-control", + "run_id": "expense-analysis-harness-20260619", + "schema_version": "wferp.report-package.v1", + "security": { + "source_secret_key_paths": [] + }, + "sql": { + "text": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly": true, + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } + }, + "validator_summary": [ + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "source_requirement_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "excel_formula_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "sql_safety_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "schema_relationship_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "metrics": { + "column_count": 7, + "row_count": 6 + }, + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "data_preview_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "report_content_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "visual_taste_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "data_visualization_reviewer", + "status": "pass" + }, + { + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [], + "role": "react_technical_reviewer", + "status": "pass" + } + ] +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-style-capsule.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-style-capsule.json new file mode 100644 index 0000000..37a2fb9 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence/report-style-capsule.json @@ -0,0 +1,73 @@ +{ + "catalog_guardrail": "financial-control", + "chart_recipe": [ + { + "id": "expense-budget-variance", + "purpose": "Compare actual expense, budget, and variance in one control view.", + "type": "combo" + }, + { + "id": "expense-composition", + "purpose": "Show expense composition across departments or categories.", + "type": "stacked-bar" + }, + { + "id": "expense-driver-ranking", + "purpose": "Rank the largest expense drivers for exception review.", + "type": "bar" + } + ], + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + }, + "interaction_recipe": { + "drilldowns": [ + "department", + "category", + "document" + ], + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ] + }, + "layout_recipe": { + "density": "analysis-first", + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ] + }, + "style_fingerprint": "4e9b070aadd45a851c859992bb6f17c7379f04dedaf09e16c23f8384e5607af7", + "style_version": "wferp.style-capsule.v1", + "table_recipe": [ + { + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "id": "expense-detail-table", + "type": "data-table" + } + ], + "visual_direction": { + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ], + "tone": "quiet financial operations dashboard" + } +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-design-brief.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-design-brief.json new file mode 100644 index 0000000..090f828 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-design-brief.json @@ -0,0 +1,86 @@ +{ + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package-for-design.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package-for-design.json new file mode 100644 index 0000000..1dc6298 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package-for-design.json @@ -0,0 +1,267 @@ +{ + "schema_version": "wferp.report-package.v1", + "run_id": "expense-analysis-harness-20260619", + "package_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "catalog_guardrail": "financial-control", + "report_type": "financial-control", + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "sql": { + "text": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + }, + "datasets": { + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "validator_summary": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ], + "accepted_residual_risks": [], + "delivery_gate": { + "allowed": false, + "blocking_validators": [ + "final_review_confirmation" + ], + "accepted_residual_risks": [] + }, + "evidence_index": [ + { + "id": "sql", + "path": "evidence/query.sql" + }, + { + "id": "execution_result", + "path": "evidence/execution-result.json" + }, + { + "id": "validators", + "path": "evidence/validator-results.json" + } + ], + "security": { + "source_secret_key_paths": [] + }, + "hashes": { + "sql_sha256": "fbeb6f14f0400fa9119389af41a62fe160eacfdab5954671ad5f9946f8e2e8f6", + "package_sha256": "62929d93e26dfe688989e91bc1486e7065cb832b99ad2b9367f8bbc75ce8dda0" + } +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package.json new file mode 100644 index 0000000..f332c11 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-package.json @@ -0,0 +1,265 @@ +{ + "schema_version": "wferp.report-package.v1", + "run_id": "expense-analysis-harness-20260619", + "package_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "catalog_guardrail": "financial-control", + "report_type": "financial-control", + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "sql": { + "text": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + }, + "datasets": { + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "validator_summary": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ], + "accepted_residual_risks": [], + "delivery_gate": { + "allowed": true, + "blocking_validators": [], + "accepted_residual_risks": [] + }, + "evidence_index": [ + { + "id": "sql", + "path": "evidence/query.sql" + }, + { + "id": "execution_result", + "path": "evidence/execution-result.json" + }, + { + "id": "validators", + "path": "evidence/validator-results.json" + } + ], + "security": { + "source_secret_key_paths": [] + }, + "hashes": { + "sql_sha256": "fbeb6f14f0400fa9119389af41a62fe160eacfdab5954671ad5f9946f8e2e8f6", + "package_sha256": "43cbe62382ef32cca00caffdeada43712dba4a28a4ceaf46df68246eb47f856a" + } +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-style-capsule.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-style-capsule.json new file mode 100644 index 0000000..76610ab --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report-style-capsule.json @@ -0,0 +1,73 @@ +{ + "catalog_guardrail": "financial-control", + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ] + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + }, + "style_version": "wferp.style-capsule.v1", + "style_fingerprint": "4e9b070aadd45a851c859992bb6f17c7379f04dedaf09e16c23f8384e5607af7" +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html new file mode 100644 index 0000000..1d13510 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html @@ -0,0 +1,77 @@ + + + + + + financial-control + + + +
    +

    financial-control

    +

    費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。

    +
    +

    Report Summary

    +

    Rows: 6

    +

    Columns: department_code, department_name, expense_subject, amount, budget_amount, variance_amount, expense_ratio

    +
    +
    +

    Query

    +
    SELECT
    +    [department_code] AS department_code,
    +    [department_name] AS department_name,
    +    [expense_subject] AS expense_subject,
    +    [amount] AS amount,
    +    [budget_amount] AS budget_amount,
    +    [amount] - [budget_amount] AS variance_amount,
    +    CAST([amount] AS REAL) / NULLIF((
    +        SELECT SUM([budget_amount])
    +        FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]
    +        WHERE [year] = 2026 AND [account_type] = 'expense'
    +    ), 0) AS expense_ratio
    +FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]
    +WHERE [year] = 2026 AND [account_type] = 'expense'
    +ORDER BY [department_code], [expense_subject]
    +
    +
    + + + diff --git a/wferp-report-runs/expense-analysis-harness-20260619/delivery/single-html-validation.json b/wferp-report-runs/expense-analysis-harness-20260619/delivery/single-html-validation.json new file mode 100644 index 0000000..efca55e --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/delivery/single-html-validation.json @@ -0,0 +1,5 @@ +{ + "valid": true, + "errors": [], + "network_references": [] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/inputs/field-formula-payload.json b/wferp-report-runs/expense-analysis-harness-20260619/inputs/field-formula-payload.json new file mode 100644 index 0000000..132f117 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/inputs/field-formula-payload.json @@ -0,0 +1,81 @@ +{ + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-field-formula", + "title": "費用分析欄位與公式確認", + "step": "excel_confirmation", + "requirementSummary": "以本地 SQLite 測試資料模擬費用分析需求,欄位包含部門、科目、金額、預算、差異與費用占比。", + "fieldFormulaReview": { + "fields": [ + { + "label": "部門代號", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_code", + "confirmation": "由費用測試資料表輸出。" + }, + { + "label": "部門名稱", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_name", + "confirmation": "用於管理報表分組與圖表標籤。" + }, + { + "label": "費用科目", + "source": "EXPENSE_ANALYSIS_FIXTURE.expense_subject", + "confirmation": "列示費用明細科目。" + }, + { + "label": "金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.amount", + "confirmation": "費用實際發生金額。" + }, + { + "label": "預算金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.budget_amount", + "confirmation": "用於差異分析。" + } + ], + "formulas": [ + { + "label": "差異金額", + "expression": "amount - budget_amount", + "confirmation": "正數代表超支。" + }, + { + "label": "費用占比", + "expression": "amount / 2026 expense budget total", + "confirmation": "以總預算 100000 作分母,最大占比預期 0.35。" + } + ] + }, + "dataPreview": { + "rowCount": 0, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [] + }, + "aggregateChecks": [], + "exceptions": [ + "本案例使用 SQLite 本地 DB 測試資料,不連線正式 DB。" + ], + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "來源、欄位與公式已確認為測試案例範圍。" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "衍生公式可由 SQL 欄位計算。" + } + ], + "actions": [ + "確認欄位與公式", + "要求修正" + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/reports/final-test-report.md b/wferp-report-runs/expense-analysis-harness-20260619/reports/final-test-report.md new file mode 100644 index 0000000..f87f411 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/reports/final-test-report.md @@ -0,0 +1,79 @@ +# WFERP Report Harness 費用分析測試報告 + +- Run ID: `expense-analysis-harness-20260619` +- Run dir: `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619` +- Prompt: 費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。 +- 結論: PASS + +## Harness 流程證據 + +| Phase | Checkpoint | Confirmation | Evidence | +| --- | --- | --- | --- | +| excel_confirmation | `checkpoints/01_excel_confirmation.json` | 確認欄位與公式 | present | +| sql_review | `checkpoints/02_sql_review.json` | 同意查詢 | present | +| data_preview | `checkpoints/03_data_preview.json` | 資料正確 | present | +| report_selection | `checkpoints/04_report_selection.json` | 產生報告 | present | +| design_brief | `checkpoints/04a_design_brief.json` | 確認設計 | present | +| visual_design | `checkpoints/04b_visual_design.json` | 確認視覺設計 | present | +| report_draft | `checkpoints/05_report_draft.json` | 接受 | present | +| final_review | `checkpoints/06_final_review.json` | 完成 | present | + +## 真實 SQL / SQLite E2E 驗收 + +| Metric | Expected | Actual | Result | +| --- | ---: | ---: | --- | +| row_count | 6 | 6 | PASS | +| total_amount | 120000 | 120000 | PASS | +| total_budget | 100000 | 100000 | PASS | +| variance_amount | 20000 | 20000 | PASS | +| max_expense_ratio | 0.35 | 0.35 | PASS | +| excluded_non_2026 | 1 | 1 | PASS | +| excluded_non_expense_account | 1 | 1 | PASS | + +SQLite fixture 建立本地 DB、插入測試資料、轉譯 SQL Server SELECT 到 SQLite、執行查詢並讀取 rows。不是 fake/mock/smoke。 + +## Single HTML Delivery 驗證 + +- HTML: `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html` +- Manifest: `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/delivery-manifest.json` +- Evidence dir: `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence` +- HTML sha256: `9bb4d82b522c691cd4f83f515ae015197dd5328d4dcf381cf7121be3b5d11c65` +- Package sha256: `43cbe62382ef32cca00caffdeada43712dba4a28a4ceaf46df68246eb47f856a` +- Style fingerprint: `4e9b070aadd45a851c859992bb6f17c7379f04dedaf09e16c23f8384e5607af7` +- Row count: `6` +- Validator status: `pass` +- Static validator valid: `True` +- Static validator errors: `[]` +- Static validator network references: `[]` + +## Screen Snapshot Evidence + +- Screenshot evidence JSON: `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/screenshot-evidence.json` +- Console errors: `0` +- External network requests: `0` + +| Screen | Expected text | File | Console errors | External requests | +| --- | --- | --- | ---: | ---: | +| 01-sql-review.png | SQL 查詢確認 | `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png` | 0 | 0 | +| 02-data-preview.png | 資料預覽確認 | `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png` | 0 | 0 | +| 03-report-selection.png | 費用分析報表格式選擇 | `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png` | 0 | 0 | +| 04-final-report.png | 2026 費用分析測試報告 | `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png` | 0 | 0 | +| 05-single-html-delivery.png | Rows: | `/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png` | 0 | 0 | + +## Screenshots + +![01-sql-review](/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png) + +![02-data-preview](/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png) + +![03-report-selection](/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png) + +![04-final-report](/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png) + +![05-single-html-delivery](/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png) + +## 結論 + +此測試案例已依 `wferp-report` harness 流程完成:SQL review、使用者確認、真 SQLite DB 查詢、data preview、報表格式選擇、dynamic design brief、visual checkpoint、report draft、final review、delivery gate、single HTML export 與 static HTML validation。 + +驗收結果為 PASS;最後交付的單檔 HTML 不連 DB、不執行 SQL、無外部 network reference,截圖 evidence 的 console errors 與 external network requests 均為 0。 diff --git a/wferp-report-runs/expense-analysis-harness-20260619/reports/report-draft.json b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-draft.json new file mode 100644 index 0000000..571aeb7 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-draft.json @@ -0,0 +1,146 @@ +{ + "kind": "report", + "title": "2026 費用分析測試報告", + "subtitle": "SQLite 本地 DB 真實 SQL 執行驗證,financial-control 樣式", + "options": { + "charts": true, + "tables": true, + "analysis": true, + "recommendations": true + }, + "sections": [ + { + "type": "analysis", + "title": "管理摘要", + "body": "2026 年費用共 6 筆,總金額 120,000,總預算 100,000,超支 20,000。行政部旅費單項占比最高,max_expense_ratio=0.35。" + }, + { + "type": "analysis", + "title": "例外排除", + "body": "排除非 2026 年費用 1 筆,排除 2026 非費用科目 1 筆,排除規則已由 SQL WHERE 條件落實。" + }, + { + "type": "recommendations", + "title": "建議事項", + "items": [ + "針對行政部旅費與研發部雲端服務建立月度預算差異追蹤。", + "保留 query.sql、SQLite execution result、validator evidence 供審計追溯。" + ] + } + ], + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "sql_safety_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "schema_relationship_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_preview_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "visual_taste_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_visualization_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "react_technical_reviewer", + "status": "pass", + "message": "pass" + } + ] +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/reports/report-render-payload.json b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-render-payload.json new file mode 100644 index 0000000..aca283a --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-render-payload.json @@ -0,0 +1,146 @@ +{ + "kind": "report", + "title": "2026 費用分析測試報告", + "subtitle": "SQLite 本地 DB 真實 SQL 執行驗證,financial-control 樣式", + "options": { + "charts": true, + "tables": true, + "analysis": true, + "recommendations": true + }, + "sections": [ + { + "type": "analysis", + "title": "管理摘要", + "body": "2026 年費用共 6 筆,總金額 120,000,總預算 100,000,超支 20,000。行政部旅費單項占比最高,max_expense_ratio=0.35。" + }, + { + "type": "analysis", + "title": "例外排除", + "body": "排除非 2026 年費用 1 筆,排除 2026 非費用科目 1 筆,排除規則已由 SQL WHERE 條件落實。" + }, + { + "type": "recommendations", + "title": "建議事項", + "items": [ + "針對行政部旅費與研發部雲端服務建立月度預算差異追蹤。", + "保留 query.sql、SQLite execution result、validator evidence 供審計追溯。" + ] + } + ], + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "sql_safety_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "schema_relationship_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_preview_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "visual_taste_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_visualization_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "react_technical_reviewer", + "status": "pass", + "message": "pass" + } + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/reports/report-selection-payload.json b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-selection-payload.json new file mode 100644 index 0000000..6173e75 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/reports/report-selection-payload.json @@ -0,0 +1,132 @@ +{ + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-report-selection", + "title": "費用分析報表格式選擇", + "step": "report_selection", + "requirementSummary": "選擇 financial-control 報告格式,包含圖表、表格、分析與建議。", + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregateChecks": [ + { + "label": "費用筆數", + "expected": 6, + "actual": 6, + "status": "pass" + }, + { + "label": "總金額", + "expected": 120000, + "actual": 120000, + "status": "pass" + }, + { + "label": "總預算", + "expected": 100000, + "actual": 100000, + "status": "pass" + }, + { + "label": "差異金額", + "expected": 20000, + "actual": 20000, + "status": "pass" + } + ], + "reportTypes": [ + { + "id": "financial-control", + "label": "財務控管", + "description": "呈現費用、預算差異與例外追蹤。" + }, + { + "id": "executive-summary", + "label": "主管摘要", + "description": "適合高階快速檢視。" + }, + { + "id": "trend-briefing", + "label": "趨勢簡報", + "description": "適合期間比較與趨勢。" + } + ], + "exceptions": [ + "排除 2025 費用 1 筆;排除 2026 非費用科目 1 筆。" + ], + "validatorEvidence": [ + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "報告選型符合費用分析目標。" + } + ], + "actions": [ + "產生報告", + "修改格式" + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/review/final-review-payload.json b/wferp-report-runs/expense-analysis-harness-20260619/review/final-review-payload.json new file mode 100644 index 0000000..660726f --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/review/final-review-payload.json @@ -0,0 +1,125 @@ +{ + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/review/final-review.json b/wferp-report-runs/expense-analysis-harness-20260619/review/final-review.json new file mode 100644 index 0000000..604117f --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/review/final-review.json @@ -0,0 +1,125 @@ +{ + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ] +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png new file mode 100644 index 0000000..f6031dd Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png new file mode 100644 index 0000000..5c640d8 Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png new file mode 100644 index 0000000..b496beb Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png new file mode 100644 index 0000000..c767f40 Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png new file mode 100644 index 0000000..1ca0d8c Binary files /dev/null and b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png differ diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/01_sql_review.json b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/01_sql_review.json new file mode 100644 index 0000000..141a2d5 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/01_sql_review.json @@ -0,0 +1,138 @@ +{ + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-sql-review", + "title": "SQL 查詢確認", + "step": "sql_review", + "requirementSummary": "確認 2026 年費用分析 SQL,只查 expense account 並排除非 2026 與非費用科目。", + "fieldFormulaReview": { + "fields": [ + { + "label": "部門代號", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_code", + "confirmation": "由費用測試資料表輸出。" + }, + { + "label": "部門名稱", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_name", + "confirmation": "用於管理報表分組與圖表標籤。" + }, + { + "label": "費用科目", + "source": "EXPENSE_ANALYSIS_FIXTURE.expense_subject", + "confirmation": "列示費用明細科目。" + }, + { + "label": "金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.amount", + "confirmation": "費用實際發生金額。" + }, + { + "label": "預算金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.budget_amount", + "confirmation": "用於差異分析。" + } + ], + "formulas": [ + { + "label": "差異金額", + "expression": "amount - budget_amount", + "confirmation": "正數代表超支。" + }, + { + "label": "費用占比", + "expression": "amount / 2026 expense budget total", + "confirmation": "以總預算 100000 作分母,最大占比預期 0.35。" + } + ] + }, + "sqlReview": { + "title": "費用分析 SQL", + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed" + }, + "schemaMapping": [ + { + "field": "department_code", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "department_code", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "department_name", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "department_name", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "expense_subject", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "expense_subject", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "budget_amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "budget_amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "variance_amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "variance_amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "expense_ratio", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "expense_ratio", + "note": "SQLite fixture / WFERP-style SQL test column" + } + ], + "relationshipPath": [ + "單表費用分析測試資料,無 join;WHERE year=2026 AND account_type=expense" + ], + "safetyChecks": [ + "read-only SELECT", + "no blocked write keywords", + "single statement" + ], + "executionEnvironment": "DB_ENV=test, SQLite local fixture" + }, + "dataPreview": { + "rowCount": 0, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [] + }, + "aggregateChecks": [], + "exceptions": [ + "SQL 尚未執行,需確認後才查 DB。" + ], + "validatorEvidence": [ + { + "validator": "sql_safety", + "status": "pass", + "message": "唯讀 SQL 驗證通過。" + } + ], + "actions": [ + "同意查詢", + "調整需求" + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/02_data_preview.json b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/02_data_preview.json new file mode 100644 index 0000000..f2ac6ea --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/02_data_preview.json @@ -0,0 +1,218 @@ +{ + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-data-preview", + "title": "資料預覽確認", + "step": "data_preview", + "requirementSummary": "確認 2026 年費用分析 SQL,只查 expense account 並排除非 2026 與非費用科目。", + "fieldFormulaReview": { + "fields": [ + { + "label": "部門代號", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_code", + "confirmation": "由費用測試資料表輸出。" + }, + { + "label": "部門名稱", + "source": "EXPENSE_ANALYSIS_FIXTURE.department_name", + "confirmation": "用於管理報表分組與圖表標籤。" + }, + { + "label": "費用科目", + "source": "EXPENSE_ANALYSIS_FIXTURE.expense_subject", + "confirmation": "列示費用明細科目。" + }, + { + "label": "金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.amount", + "confirmation": "費用實際發生金額。" + }, + { + "label": "預算金額", + "source": "EXPENSE_ANALYSIS_FIXTURE.budget_amount", + "confirmation": "用於差異分析。" + } + ], + "formulas": [ + { + "label": "差異金額", + "expression": "amount - budget_amount", + "confirmation": "正數代表超支。" + }, + { + "label": "費用占比", + "expression": "amount / 2026 expense budget total", + "confirmation": "以總預算 100000 作分母,最大占比預期 0.35。" + } + ] + }, + "sqlReview": { + "title": "費用分析 SQL", + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed" + }, + "schemaMapping": [ + { + "field": "department_code", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "department_code", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "department_name", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "department_name", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "expense_subject", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "expense_subject", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "budget_amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "budget_amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "variance_amount", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "variance_amount", + "note": "SQLite fixture / WFERP-style SQL test column" + }, + { + "field": "expense_ratio", + "table": "EXPENSE_ANALYSIS_FIXTURE", + "column": "expense_ratio", + "note": "SQLite fixture / WFERP-style SQL test column" + } + ], + "relationshipPath": [ + "單表費用分析測試資料,無 join;WHERE year=2026 AND account_type=expense" + ], + "safetyChecks": [ + "read-only SELECT", + "no blocked write keywords", + "single statement" + ], + "executionEnvironment": "DB_ENV=test, SQLite local fixture" + }, + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregateChecks": [ + { + "label": "費用筆數", + "expected": 6, + "actual": 6, + "status": "pass" + }, + { + "label": "總金額", + "expected": 120000, + "actual": 120000, + "status": "pass" + }, + { + "label": "總預算", + "expected": 100000, + "actual": 100000, + "status": "pass" + }, + { + "label": "差異金額", + "expected": 20000, + "actual": 20000, + "status": "pass" + } + ], + "exceptions": [ + "排除 2025 費用 1 筆;排除 2026 非費用科目 1 筆。" + ], + "validatorEvidence": [ + { + "validator": "data_preview", + "status": "pass", + "message": "SQLite 查詢結果與量化驗收一致。" + } + ], + "actions": [ + "資料正確", + "重新查詢" + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/03_report_selection.json b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/03_report_selection.json new file mode 100644 index 0000000..6173e75 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/03_report_selection.json @@ -0,0 +1,132 @@ +{ + "kind": "checkpoint", + "checkpointId": "expense-analysis-harness-20260619-report-selection", + "title": "費用分析報表格式選擇", + "step": "report_selection", + "requirementSummary": "選擇 financial-control 報告格式,包含圖表、表格、分析與建議。", + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "aggregateChecks": [ + { + "label": "費用筆數", + "expected": 6, + "actual": 6, + "status": "pass" + }, + { + "label": "總金額", + "expected": 120000, + "actual": 120000, + "status": "pass" + }, + { + "label": "總預算", + "expected": 100000, + "actual": 100000, + "status": "pass" + }, + { + "label": "差異金額", + "expected": 20000, + "actual": 20000, + "status": "pass" + } + ], + "reportTypes": [ + { + "id": "financial-control", + "label": "財務控管", + "description": "呈現費用、預算差異與例外追蹤。" + }, + { + "id": "executive-summary", + "label": "主管摘要", + "description": "適合高階快速檢視。" + }, + { + "id": "trend-briefing", + "label": "趨勢簡報", + "description": "適合期間比較與趨勢。" + } + ], + "exceptions": [ + "排除 2025 費用 1 筆;排除 2026 非費用科目 1 筆。" + ], + "validatorEvidence": [ + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "報告選型符合費用分析目標。" + } + ], + "actions": [ + "產生報告", + "修改格式" + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/04_report.json b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/04_report.json new file mode 100644 index 0000000..aca283a --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/payloads/04_report.json @@ -0,0 +1,146 @@ +{ + "kind": "report", + "title": "2026 費用分析測試報告", + "subtitle": "SQLite 本地 DB 真實 SQL 執行驗證,financial-control 樣式", + "options": { + "charts": true, + "tables": true, + "analysis": true, + "recommendations": true + }, + "sections": [ + { + "type": "analysis", + "title": "管理摘要", + "body": "2026 年費用共 6 筆,總金額 120,000,總預算 100,000,超支 20,000。行政部旅費單項占比最高,max_expense_ratio=0.35。" + }, + { + "type": "analysis", + "title": "例外排除", + "body": "排除非 2026 年費用 1 筆,排除 2026 非費用科目 1 筆,排除規則已由 SQL WHERE 條件落實。" + }, + { + "type": "recommendations", + "title": "建議事項", + "items": [ + "針對行政部旅費與研發部雲端服務建立月度預算差異追蹤。", + "保留 query.sql、SQLite execution result、validator evidence 供審計追溯。" + ] + } + ], + "dataPreview": { + "rowCount": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ] + }, + "validatorEvidence": [ + { + "validator": "source_requirement_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "excel_formula_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "sql_safety_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "schema_relationship_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_preview_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "report_content_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "visual_taste_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "data_visualization_reviewer", + "status": "pass", + "message": "pass" + }, + { + "validator": "react_technical_reviewer", + "status": "pass", + "message": "pass" + } + ] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/screenshots/screenshot-evidence.json b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/screenshot-evidence.json new file mode 100644 index 0000000..43be58c --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/screenshots/screenshot-evidence.json @@ -0,0 +1,37 @@ +[ + { + "file": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/01-sql-review.png", + "url": "http://127.0.0.1:4188/?payload=/wferp-screenshots-expense/01_sql_review.json", + "expectedText": "SQL 查詢確認", + "consoleErrors": [], + "externalNetworkRequests": [] + }, + { + "file": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/02-data-preview.png", + "url": "http://127.0.0.1:4188/?payload=/wferp-screenshots-expense/02_data_preview.json", + "expectedText": "資料預覽確認", + "consoleErrors": [], + "externalNetworkRequests": [] + }, + { + "file": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/03-report-selection.png", + "url": "http://127.0.0.1:4188/?payload=/wferp-screenshots-expense/03_report_selection.json", + "expectedText": "費用分析報表格式選擇", + "consoleErrors": [], + "externalNetworkRequests": [] + }, + { + "file": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/04-final-report.png", + "url": "http://127.0.0.1:4188/?payload=/wferp-screenshots-expense/04_report.json", + "expectedText": "2026 費用分析測試報告", + "consoleErrors": [], + "externalNetworkRequests": [] + }, + { + "file": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/screenshots/05-single-html-delivery.png", + "url": "file:///home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html", + "expectedText": "Rows:", + "consoleErrors": [], + "externalNetworkRequests": [] + } +] \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql b/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql new file mode 100644 index 0000000..d44fb7a --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql @@ -0,0 +1,15 @@ +SELECT + [department_code] AS department_code, + [department_name] AS department_name, + [expense_subject] AS expense_subject, + [amount] AS amount, + [budget_amount] AS budget_amount, + [amount] - [budget_amount] AS variance_amount, + CAST([amount] AS REAL) / NULLIF(( + SELECT SUM([budget_amount]) + FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] + WHERE [year] = 2026 AND [account_type] = 'expense' + ), 0) AS expense_ratio +FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE] +WHERE [year] = 2026 AND [account_type] = 'expense' +ORDER BY [department_code], [expense_subject] diff --git a/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql.json b/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql.json new file mode 100644 index 0000000..8b8ff41 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/sql/query.sql.json @@ -0,0 +1,9 @@ +{ + "sql": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + } +} diff --git a/wferp-report-runs/expense-analysis-harness-20260619/state.json b/wferp-report-runs/expense-analysis-harness-20260619/state.json new file mode 100644 index 0000000..c4bbb58 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/state.json @@ -0,0 +1,492 @@ +{ + "run_id": "expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "input_files": [], + "schema_snapshot": null, + "excel_requirement": null, + "sql_candidate": "SELECT\n [department_code] AS department_code,\n [department_name] AS department_name,\n [expense_subject] AS expense_subject,\n [amount] AS amount,\n [budget_amount] AS budget_amount,\n [amount] - [budget_amount] AS variance_amount,\n CAST([amount] AS REAL) / NULLIF((\n SELECT SUM([budget_amount])\n FROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\n WHERE [year] = 2026 AND [account_type] = 'expense'\n ), 0) AS expense_ratio\nFROM [VPIC1].[dbo].[EXPENSE_ANALYSIS_FIXTURE]\nWHERE [year] = 2026 AND [account_type] = 'expense'\nORDER BY [department_code], [expense_subject]", + "sql_validation": { + "readonly": true, + "blockedKeywords": [], + "executionStatus": "not_executed", + "readonly_reason": "SQL starts with SELECT and contains no blocked write keywords or multi statements." + }, + "execution_result_summary": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "rows": [ + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "文具", + "amount": 10000, + "budget_amount": 8000, + "variance_amount": 2000, + "expense_ratio": 0.1 + }, + { + "department_code": "ADM", + "department_name": "行政部", + "expense_subject": "旅費", + "amount": 35000, + "budget_amount": 30000, + "variance_amount": 5000, + "expense_ratio": 0.35 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "軟體訂閱", + "amount": 25000, + "budget_amount": 22000, + "variance_amount": 3000, + "expense_ratio": 0.25 + }, + { + "department_code": "RND", + "department_name": "研發部", + "expense_subject": "雲端服務", + "amount": 30000, + "budget_amount": 25000, + "variance_amount": 5000, + "expense_ratio": 0.3 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "交際費", + "amount": 12000, + "budget_amount": 10000, + "variance_amount": 2000, + "expense_ratio": 0.12 + }, + { + "department_code": "SAL", + "department_name": "業務部", + "expense_subject": "廣告費", + "amount": 8000, + "budget_amount": 5000, + "variance_amount": 3000, + "expense_ratio": 0.08 + } + ], + "aggregates": { + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35 + }, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + }, + "execution": { + "engine": "SQLite", + "db_env": "test", + "real_sql_execution": true + } + }, + "report_type": "financial-control", + "report_design": "financial-control", + "report_design_brief": { + "schema_version": "wferp.design-brief.v1", + "report_intent": { + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "report_type": "financial-control", + "primary_goal": "Expose financial-control exceptions and decision-ready expense signals." + }, + "catalog_guardrail": "financial-control", + "target_audience": { + "role": "finance controller", + "needs": [ + "budget variance review", + "expense concentration review", + "audit follow-up" + ] + }, + "layout_recipe": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "chart_recipe": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "table_recipe": [ + { + "id": "expense-detail-table", + "type": "data-table", + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interaction_recipe": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "embedded_data_policy": { + "mode": "smart-tiered", + "summary_threshold_rows": 5000 + } + }, + "visual_design_checkpoint": { + "title": "費用分析視覺設計確認", + "catalog_guardrail": "financial-control", + "layout": { + "mode": "kpi-first-dashboard", + "sections": [ + "executive-summary", + "kpi-overview", + "expense-trend", + "exception-review", + "data-table" + ], + "density": "analysis-first" + }, + "kpis": [ + { + "label": "total_amount", + "value": 120000 + }, + { + "label": "total_budget", + "value": 100000 + }, + { + "label": "variance_amount", + "value": 20000 + }, + { + "label": "max_expense_ratio", + "value": 0.35 + } + ], + "charts": [ + { + "id": "expense-budget-variance", + "type": "combo", + "purpose": "Compare actual expense, budget, and variance in one control view." + }, + { + "id": "expense-composition", + "type": "stacked-bar", + "purpose": "Show expense composition across departments or categories." + }, + { + "id": "expense-driver-ranking", + "type": "bar", + "purpose": "Rank the largest expense drivers for exception review." + } + ], + "tables": [ + { + "id": "expense-detail-table", + "type": "data-table", + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "features": [ + "filter", + "sort", + "drilldown", + "column_visibility" + ], + "row_count": 6 + } + ], + "interactions": { + "filters": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "drilldowns": [ + "department", + "category", + "document" + ] + }, + "visual_direction": { + "tone": "quiet financial operations dashboard", + "emphasis": [ + "variance", + "outliers", + "approval-ready summary" + ] + }, + "data_profile": { + "row_count": 6, + "columns": [ + "department_code", + "department_name", + "expense_subject", + "amount", + "budget_amount", + "variance_amount", + "expense_ratio" + ], + "embedded_mode": "full_rows", + "embedded_rows": 6, + "full_rows_in_evidence_packet": false + } + }, + "report_options": { + "include_chart": true, + "include_table": true, + "include_analysis": true, + "include_recommendations": true + }, + "validator_results": [ + { + "role": "source_requirement_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "excel_formula_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "sql_safety_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "schema_relationship_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_preview_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass", + "metrics": { + "row_count": 6, + "column_count": 7 + } + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "report_content_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "visual_taste_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "data_visualization_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + }, + { + "role": "react_technical_reviewer", + "status": "pass", + "evidence": [ + { + "artifact": "expense-analysis-harness", + "status": "pass" + } + ], + "findings": [], + "requiredFixes": [], + "residualRisks": [] + } + ], + "user_confirmations": { + "excel_confirmation": "確認欄位與公式", + "sql_review": "同意查詢", + "data_preview": "資料正確", + "report_selection": "產生報告", + "design_brief": "確認設計", + "visual_design": "確認視覺設計", + "report_draft": "接受", + "final_review": "完成" + }, + "checkpoints": [ + { + "checkpoint": "excel_confirmation", + "file": "checkpoints/01_excel_confirmation.json", + "created_at": "2026-06-18T23:46:59.396754+00:00" + }, + { + "checkpoint": "sql_review", + "file": "checkpoints/02_sql_review.json", + "created_at": "2026-06-18T23:47:00.383655+00:00" + }, + { + "checkpoint": "data_preview", + "file": "checkpoints/03_data_preview.json", + "created_at": "2026-06-18T23:47:01.410077+00:00" + }, + { + "checkpoint": "report_selection", + "file": "checkpoints/04_report_selection.json", + "created_at": "2026-06-18T23:47:02.452321+00:00" + }, + { + "checkpoint": "design_brief", + "file": "checkpoints/04a_design_brief.json", + "created_at": "2026-06-18T23:47:03.481659+00:00" + }, + { + "checkpoint": "visual_design", + "file": "checkpoints/04b_visual_design.json", + "created_at": "2026-06-18T23:47:04.480122+00:00" + }, + { + "checkpoint": "report_draft", + "file": "checkpoints/05_report_draft.json", + "created_at": "2026-06-18T23:47:05.487094+00:00" + }, + { + "checkpoint": "final_review", + "file": "checkpoints/06_final_review.json", + "created_at": "2026-06-18T23:47:06.480467+00:00" + } + ], + "created_at": "2026-06-18T23:46:58.810320+00:00", + "updated_at": "2026-06-18T23:47:06.978348+00:00", + "user_confirmation_options": { + "excel_confirmation": {}, + "sql_review": {}, + "data_preview": {}, + "report_selection": { + "reportType": "financial-control" + }, + "design_brief": {}, + "visual_design": {}, + "report_draft": {}, + "final_review": {} + }, + "accepted_residual_risks": [] +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/test-summary.json b/wferp-report-runs/expense-analysis-harness-20260619/test-summary.json new file mode 100644 index 0000000..646a5dc --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/test-summary.json @@ -0,0 +1,27 @@ +{ + "run_id": "expense-analysis-harness-20260619", + "run_dir": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619", + "prompt": "費用分析測試案例:查詢 2026 年費用科目,依部門與科目列出金額、預算、差異與費用占比,產出財務控管報告。", + "sqlite_e2e": { + "real_sql_execution": true, + "row_count": 6, + "total_amount": 120000, + "total_budget": 100000, + "variance_amount": 20000, + "max_expense_ratio": 0.35, + "excluded_rows": { + "non_2026": 1, + "non_expense_account": 1 + } + }, + "single_html_validation": { + "valid": true, + "errors": [], + "network_references": [] + }, + "delivery_files": { + "single_html": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/report.html", + "manifest": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/delivery-manifest.json", + "evidence": "/home/timmypai/.codex/worktrees/5f5b/wferp/wferp-report-runs/expense-analysis-harness-20260619/delivery/evidence" + } +} \ No newline at end of file diff --git a/wferp-report-runs/expense-analysis-harness-20260619/visual/visual-checkpoint.html b/wferp-report-runs/expense-analysis-harness-20260619/visual/visual-checkpoint.html new file mode 100644 index 0000000..05d5578 --- /dev/null +++ b/wferp-report-runs/expense-analysis-harness-20260619/visual/visual-checkpoint.html @@ -0,0 +1,54 @@ + + + + + 費用分析視覺設計確認 + + + +
    +

    費用分析視覺設計確認

    +
    +

    版面

    +

    kpi-first-dashboard / analysis-first

    +
    • executive-summary
    • kpi-overview
    • expense-trend
    • exception-review
    • data-table
    +
    +
    +

    KPI

    +
    total_amount120000
    total_budget100000
    variance_amount20000
    max_expense_ratio0.35
    +
    +
    +

    圖表目的

    +
    • comboCompare actual expense, budget, and variance in one control view.
    • stacked-barShow expense composition across departments or categories.
    • barRank the largest expense drivers for exception review.
    +
    +
    +

    表格

    +
    • expense-detail-tabledepartment_code, department_name, expense_subject, amount, budget_amount, variance_amount, expense_ratio
    +
    +
    +

    互動

    +

    篩選

    +
    • department_code
    • department_name
    • expense_subject
    • amount
    • budget_amount
    • variance_amount
    • expense_ratio
    +

    下鑽

    +
    • department
    • category
    • document
    +
    +
    +

    視覺方向

    +

    quiet financial operations dashboard

    +
    • variance
    • outliers
    • approval-ready summary
    +
    +
    +

    資料輪廓

    +

    rows: 6

    +

    columns: department_code, department_name, expense_subject, amount, budget_amount, variance_amount, expense_ratio

    +
    +
    + + \ No newline at end of file