diff --git a/use-cases/Priyanshu2425/.gitignore b/use-cases/Priyanshu2425/.gitignore new file mode 100644 index 00000000..404e2f18 --- /dev/null +++ b/use-cases/Priyanshu2425/.gitignore @@ -0,0 +1,9 @@ +# Build artefacts from running the two projects in this folder. Kept here +# rather than at the repository root, because CONTRIBUTING.md asks each builder +# to change nothing outside their own folder. +__pycache__/ +*.pyc +.pytest_cache/ +node_modules/ +*.tsbuildinfo +.DS_Store diff --git a/use-cases/Priyanshu2425/quota-aware-agent/LICENSE b/use-cases/Priyanshu2425/quota-aware-agent/LICENSE new file mode 100644 index 00000000..cb00ec18 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Priyanshu Semwal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/use-cases/Priyanshu2425/quota-aware-agent/README.md b/use-cases/Priyanshu2425/quota-aware-agent/README.md new file mode 100644 index 00000000..e256fed5 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/README.md @@ -0,0 +1,363 @@ +# Quota-aware agent + +**Assigned build A · band S1 · MCP/API · SuperDocs** + +An agent that reads its remaining allowance **before** it plans, sizes the work +to fit, and when the work does not fit it degrades and says so in a sentence a +person can read — instead of starting a job it cannot finish and dying halfway +through someone's document. + +![The agent degrading, twice](screenshot.png) + +## Setup and test + +**Zero setup to see it work.** No key, no network, no install — the transport is +injected and the tests answer with a documented fake, so nothing here costs an +operation: + +```bash +git clone https://github.com/superdocsapp/superdocs-builds.git +cd superdocs-builds/use-cases/Priyanshu2425/quota-aware-agent + +python3 -m pytest # 71 passed, 1 skipped — the one skip is the MCP protocol check +python3 backend/demo.py # watch it plan, size the work, run it, and export +``` + +Python 3.10 or newer. There are **no runtime dependencies** — that is why the +two commands above work on a clone with nothing installed. + +
+The MCP surface — one extra install + +```bash +pip install -e ".[mcp]" +export SUPERDOCS_API_KEY=your-key-here # placeholder; never commit a real key +python3 -m quota_aware_agent.mcp_server # stdio +``` + +Or register it with a client: + +```bash +claude mcp add quota-aware-agent \ + --env SUPERDOCS_API_KEY=your-key-here \ + -- python3 -m quota_aware_agent.mcp_server +``` + +With the SDK installed the suite runs **72 passed, nothing skipped** — the extra +test starts the real server over stdio and drives it with a real client, because +a server that imports cleanly and cannot start is worse than one that fails +loudly. +
+ +
+Against the real API — this one spends operations + +```bash +export SUPERDOCS_API_KEY=your-key-here +python3 backend/demo.py --live --sample 1 # one step, one operation +``` + +`--sample N` is the small-sample bound. Use it. Exports and `whoami` are free; +edits are not. +
+ +### Environment + +| Variable | Required | What it is | +|---|---|---| +| `SUPERDOCS_API_KEY` | only for `--live` and the MCP server | Your SuperDocs API key. An agent can create its own account with `POST /v1/agents/signup`. | +| `QUOTA_AWARE_AGENT_LEDGER` | no | Where the operation ledger is kept. Defaults to `~/.quota-aware-agent/operations.jsonl`. **Set it per account** — two agents driving different accounts must not share one. | + +### What to run to check each claim + +| Claim | Command | +|---|---| +| It sizes work to fit and names what it dropped | `python3 backend/demo.py --scenario tight` | +| It refuses to start rather than half-finish | `python3 backend/demo.py --scenario broke` | +| It can refuse a partial run outright | `python3 backend/demo.py --scenario tight --refuse` | +| It bounds anything that loops | `python3 backend/demo.py --sample 2` | +| It says what it spent, and whether that adds up | `python3 backend/demo.py --receipt` | +| A rerun does not pay twice | `python3 backend/demo.py --scenario tight --ledger /tmp/ops.jsonl` — **twice** | + +The second run of that last one declines to repeat the first run's work and says +so. That is graceful re-entry shown rather than asserted. + +## What it uses from SuperDocs + +| Surface | Used for | +|---|---| +| `GET /v1/agents/whoami` | The one authoritative allowance read available to an API key. Free. | +| `POST /v1/documents/upload` | The document. Multipart; the filename extension decides the parser. | +| `POST /v1/chat/async` | The edit instruction, with `approval_mode: ask_every_time`. **The only billable call.** | +| `GET /v1/jobs/{id}` | Polling. Silence is treated as processing, never as a crash. | +| `POST /v1/chat/{session_id}/approve` | Approving proposed changes, item by item. | +| `POST /v1/documents/export` | The finished file. Free, so it runs even when the run stopped early. | +| **MCP** | The delivery surface — four tools, of which one costs anything. | + +## The problem, stated precisely + +An agent that plans without checking its allowance starts work it cannot +finish. The obvious fix — "check the balance first" — runs into something the +SuperDocs documentation is explicit about: + +> the `/v1/users/me/usage` and `/v1/users/me/limits` endpoints belong to the +> web-app account surface and accept web-app session tokens only — they reject +> `sk_` / `lce_` API keys with a `401` + +**So an API-key agent cannot read its balance on demand.** There is no endpoint +to poll. What exists is `GET /v1/agents/whoami` once at the start, and the +`usage` block that rides on **every** chat response afterwards. You learn your +balance as a side effect of doing work, not in advance of it. + +This build takes that seriously rather than papering over it. `Balance` carries +an `authoritative` flag, and every report says which kind of number it used: + +``` +Starting allowance: 3 operations (confirmed). +Allowance now: 1 operation (confirmed). +``` + +A number inferred between calls prints as `(estimated)` and can never be +presented as a live read. That distinction is a tested property, not a comment. + +## What it does when the work does not fit + +Three behaviours, each one tested: + +**It refuses to start.** If nothing fits, nothing is uploaded and nothing is +billed. There is no partially-edited document to clean up. + +``` +Did not start: none of the requested work fits inside the remaining allowance. +Nothing was uploaded and nothing was billed. +``` + +**It degrades by severity.** If some of the work fits, the highest-severity +work goes first and the rest is named — not silently dropped. + +``` +The full request would cost about 4 operation(s). Sized to fit: 50 of 100 +changed sections (2 of 4 operations). Deferred 2 lower-severity change(s) to +stay inside the remaining allowance. +Left undone: terms, footer. These were not started, so nothing is half-applied. +``` + +**It holds a reserve.** The last operation is never spent on new edits, so the +work already done can always be exported. Exports are free, so this costs the +user nothing and guarantees they end up with a file. + +**It does not pay twice for the same edit.** SuperDocs has no idempotency on +billable writes — there is no idempotency key on `POST /v1/chat/async` — so a +rerun after an interrupted batch is charged again for work already in the +document. An operation ledger, keyed on what the call *is* rather than on an id +the caller assigned, is written **before** each billable call and consulted +before each one: + +``` +'terms': an earlier run already applied this. Not repeated, and not billed again. +``` + +The hard case is handled as a hard case. A call that was sent, and whose outcome +this process died before learning, is **neither repeated nor forgotten** — +retrying it might be charged twice and might apply the same edit twice, and +skipping it might leave the work undone. There is no safe automatic answer, so +it is reported: + +``` +'dates': an earlier run started this and never learned whether it finished. +Not retried — that might be charged twice and might apply the same edit twice. +``` + +A person opens the document, sees whether the edit is there, and says so through +`resolve_operation`. That exit has to exist: a step that is never retried +automatically and cannot be resolved through the surface is a step that can +never run again. + +**It knows whether a failed call could have been billed.** A refused connection +proves the request never reached SuperDocs, so a rerun may safely repeat it. A +read timeout proves nothing — the request very possibly arrived, was charged and +applied an edit, and only the answer was lost. Collapsing those two into "it +failed" is how a retry pays twice, so they are recorded as different things and +the default answer is *we do not know*. + +**It says what it spent, and whether that adds up.** `--receipt` prints the line +items and reconciles them against the allowance — and when they disagree, it +says so rather than picking a side: + +``` +DOES IT ADD UP? +2 operation(s) charged, and the allowance moved by 2. These agree. +``` + +A `~` marks an operation we *believe* was charged on a call that returned no +usage block, and a `?` marks a balance we inferred. Confirmed and estimated +figures are never added together, because one number that is part measurement +and part belief tells the reader nothing about which part is which. + +### The stopping rule, stated once + +1. `quota_exhausted` on any response — the platform's own signal, and the only + authoritative one. Stop immediately. Our arithmetic never overrules it. +2. The reserve floor — never spend the last operation. +3. `--sample N` — the small-sample bound, because anything that loops needs one. + +All three live in `Policy`, which is frozen, so a run ends under the policy it +began with. And every one of them **explains what it was protecting** when it +fires. That is not decoration: a limit whose only feedback is *"I stopped"* +trains the person who set it to raise the number until it stops firing, which is +the same as not having it. + +## How the work is priced, and why that is the whole ballgame + +SuperDocs bills a **request**: most bill one operation, and very large ones bill +one per 25 sections edited. The floor of one is therefore *per request*, not per +plan — and this agent sends one request per step. + +That distinction is not a rounding error. Four five-section edits pooled to +twenty sections price as **one** operation and bill as **four**. An agent that +prices them pooled reports "it fits", starts, and runs out partway through +somebody's document — which is the exact failure this build exists to prevent, +arriving through its own arithmetic. `estimate(changes, batched=False)` is what +the agent uses, `batched=True` is what a publisher sending one request uses, and +`QuotaAwareAgent.BATCHED` names which one this is so the planner and the +executor cannot drift apart about what a step costs. + +## Use it as an MCP server + +The card is band S1 · MCP and the user is an agent, so the agent is reachable +as one. Four tools, in the order an agent actually needs them: + +| Tool | Costs | What it answers | +|---|---|---| +| `check_allowance` | free | What have I got? The one authoritative read — plus anything an earlier run left unresolved. | +| `plan_work` | free | What fits inside it — without doing any of it. Given a `session_id` it also prices against the ledger, so work an earlier run already paid for is named rather than quoted. | +| `run_work` | billable | Do the part that fits; say what was left out, what was already paid for, and what it cost. Takes HTML, or the bytes of a real `.docx` as `document_base64`. | +| `resolve_operation` | free | A person's answer about a call that was started and never confirmed. Without it that step could never run again — a state the surface could enter and not leave. | + +Both `plan_work` and `run_work` take `when_it_does_not_fit`: `degrade` (do the +highest-severity part that fits, the default) or `refuse` (start nothing rather +than deliver a subset). + +**Every result carries a `budget` block**, so a calling agent never has to spend +a turn asking what is left before it decides what to do. It carries the +remaining operations, whether that number is authoritative or inferred, the +reserve, and what is therefore spendable on new edits. Surfacing the budget into +the caller's own context is the cheapest useful thing this build does: an agent +that knows what is left can decide earlier, do less, or escalate — and an agent +that has to ask cannot. + +``` +pip install -e ".[mcp]" +export SUPERDOCS_API_KEY=your-key-here +python3 -m quota_aware_agent.mcp_server # stdio +``` + +Claude Code: + +``` +claude mcp add quota-aware-agent \ + --env SUPERDOCS_API_KEY=your-key-here \ + -- python3 -m quota_aware_agent.mcp_server +``` + +**`plan_work` exists as its own tool on purpose.** An agent that has to commit +to work before it can find out what fits is the exact failure this build is +about, so the plan is readable for free and changes nothing. A test asserts that +planning makes no billable call, and another asserts the plan and the run agree +about what fits — if they could disagree, only one of them would be tested. + +Every tool is a thin wrapper over `QuotaAwareAgent`; no planning logic lives in +the MCP layer. `dispatch()` calls the whole surface without a protocol client, +which is what keeps it testable offline. + +## The four-call contract + +`upload` → `edit instruction` → `approve` → `export`, built first and built +completely, before any depth. + +| Call | Endpoint | Billing | +|---|---|---| +| Upload | `POST /v1/documents/upload` | free | +| Edit instruction | `POST /v1/chat/async` (`approval_mode: ask_every_time`) | 1 op per 25 sections | +| Approve | `POST /v1/chat/{session_id}/approve` | denied changes are never billed | +| Export | `POST /v1/documents/export` | free | + +### The three named traps, all handled + +**Trap 1 · the double parse.** A `proposed_change_batch` envelope carries its +payload as a JSON-encoded *string* in `content`. It needs a second parse. Miss +it and you get diff cards where every field reads `undefined` — silently, with +no error. `parse_proposed_changes` does it, and the test asserts the change id +actually reached the approve call, because a test that only checks "it ran" +would pass while approving nothing. + +**Trap 2 · silence is not a crash.** A job on a large document can run for +minutes with no output. `poll_job` treats quiet as processing and gives up only +at an explicit deadline — and when it does, it says the deadline was *ours*: + +> job job-1 was still 'in_progress' after 4s. That is a deadline this client +> imposed, not a platform failure — the job may still be running. + +**Trap 3 · the allowance.** The whole point of this build. Small-sample mode +and the stopping rule above. + +## Why the tests need no key + +The transport is injected. `tests/fake.py` implements the documented response +shapes — including the double-parsed envelope, a `usage` block on billable +responses, and the `400` the live upload endpoint returns for a filename whose +extension disagrees with its bytes. The demo answers with the **same** fake the +suite uses, so what a reviewer watches is what the tests assert; a second fake +written only for the demo would be free to flatter the code that calls it. + +See **Setup and test** at the top for every command. + +## Shared core — stated plainly + +`backend/quota_aware_agent/budget.py` is shared, unchanged, with another system by the +same author, where it guards the publisher that writes documents back to +SuperDocs. It is vendored here rather than imported so this build stands alone +in this repository. + +Reuse is only a shortcut when it is hidden. The same module solving both +problems is the argument that the abstraction is right: "never start work you +cannot finish" is one idea, and an agent sizing a plan and a publisher sizing a +document write are the same shape of problem. + +## Honest limitations + +- **The allowance is not continuously known, and this build does not pretend it + is.** It is authoritative at `whoami` and after each response, and an estimate + in between. Every report labels which. +- **Section counts for planned edits are supplied by the caller, not measured.** + SuperDocs bills per 25 sections *edited*, which is known only after the edit. + A caller who badly underestimates can still overshoot — which is exactly why + `quota_exhausted` is treated as authoritative and the reserve exists. +- **The operation ledger is this client's memory, not the platform's.** It + prevents *this tool* from paying twice. It cannot prevent a different client, + or a person in the web app, from making the same edit — nothing below us + offers idempotency to build that on. +- **The ledger is not locked against two processes running at once.** It is read + at start-up and appended to, so two runs launched simultaneously against the + same steps can both see "not yet attempted" and both pay. Sequential reruns — + the crash-and-resume case it was built for — are safe. A file lock would close + the concurrent case and is not built. +- **A failure whose outcome is unknown needs a person, by design.** The agent + will not guess between paying twice and leaving work undone, so those steps + stay blocked until someone answers with `resolve_operation`. +- **A reconciliation needs two authoritative balances.** If either end of the + run was inferred, the receipt says the run cannot be reconciled rather than + reconciling two guesses. +- **It approves every proposed change in the demo.** The approve call supports + per-change denial with feedback and the client exposes it; deciding *which* + changes are worth approving is a different problem from affording them. +- It does not implement the pre-signed upload flow, so it is limited to the + ~20 MB direct-upload ceiling. + +## Credit + +Built by **Priyanshu Semwal** ([@Priyanshu2425](https://github.com/Priyanshu2425)) +for the SuperDocs engineer round, 2026. MIT licensed — see [LICENSE](LICENSE). + +Grounded throughout in the SuperDocs API documentation; where the task brief and +the documentation differed, the documentation won. diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/demo.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/demo.py new file mode 100644 index 00000000..90524061 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/demo.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Run the agent. With no key it runs against a documented fake, so a reviewer +can see the behaviour without spending an operation. + + python3 backend/demo.py # offline, against the fake + python3 backend/demo.py --scenario tight # not enough allowance, it degrades + python3 backend/demo.py --scenario broke # no allowance, it refuses to start + python3 backend/demo.py --live # real API, needs SUPERDOCS_API_KEY + + --sample N small-sample mode: run at most N steps + --receipt print the line items and whether they add up + --refuse refuse a partial run rather than deliver a subset + --ledger F keep the operation ledger in F, so running twice shows what + a rerun after a crash does NOT pay for a second time +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +# The offline demo answers with `tests/fake.py`, the same fake the suite uses, +# so what a reviewer watches here is what the tests assert. That lives beside +# this package rather than inside it, so the project root goes on the path -- +# the alternative is a second fake, and a fake nobody wrote cannot flatter the +# code that calls it. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from quota_aware_agent.policy import Policy, WhenItDoesNotFit +from quota_aware_agent import QuotaAwareAgent, Step, SuperDocsClient +from quota_aware_agent.client import HttpTransport +from quota_aware_agent.idempotency import OperationLedger + +WORK = [ + Step("figures", "Correct the revenue figures in the summary table.", 25, "critical"), + Step("dates", "Fix the effective dates in section 3.", 25, "high"), + Step("terms", "Align the defined terms with the glossary.", 25, "medium"), + Step("footer", "Tidy the footer and page numbering.", 25, "low"), +] + +SCENARIOS = {"roomy": 500, "tight": 3, "broke": 1} + + +def main(argv=None) -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--scenario", choices=sorted(SCENARIOS), default="roomy") + p.add_argument("--live", action="store_true", help="use the real API") + p.add_argument("--sample", type=int, default=None, help="run at most N steps") + # HTML content, so an HTML name. SuperDocs picks its parser from the + # extension, so "contract.docx" holding HTML is a 400 on a live run. + p.add_argument("--file", default="contract.html") + p.add_argument("--receipt", action="store_true", + help="print the line items and whether they add up") + p.add_argument("--refuse", action="store_true", + help="refuse a partial run rather than deliver a subset") + p.add_argument("--ledger", default=None, + help="keep the operation ledger in this file, so a second " + "run sees what the first one paid for") + a = p.parse_args(argv) + + if a.live: + key = os.environ.get("SUPERDOCS_API_KEY") + if not key: + print("SUPERDOCS_API_KEY is not set. Run without --live to use the fake.", + file=sys.stderr) + return 2 + transport = HttpTransport(key) + sleep = None + print("Running against the real API. Operations will be billed.\n") + else: + from tests.fake import FakeSuperDocs + transport = FakeSuperDocs(remaining=SCENARIOS[a.scenario]) + sleep = lambda s: None + print(f"Offline demo, scenario '{a.scenario}': " + f"{SCENARIOS[a.scenario]} operation(s) of allowance. Nothing is billed.\n") + + client = SuperDocsClient(transport, **({"sleep": sleep} if sleep else {})) + policy = Policy( + reserve=1, max_steps=a.sample, + when_it_does_not_fit=(WhenItDoesNotFit.REFUSE if a.refuse + else WhenItDoesNotFit.DEGRADE), + ) + agent = QuotaAwareAgent(client, policy=policy, + ledger=OperationLedger(a.ledger) if a.ledger else None) + + content = b"

Quarterly report

...

" + report = agent.run("demo-session", a.file, content, WORK) + + print(report.plain_language()) + print() + print(f" planned: {report.planned or '-'}") + print(f" completed: {report.completed or '-'}") + print(f" deferred: {report.deferred or '-'}") + print(f" stopped: {report.stop_reason.value}") + if a.receipt: + print() + print(report.receipt.render_text(report.balance_at_start, + report.balance_at_end)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/__init__.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/__init__.py new file mode 100644 index 00000000..48338352 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/__init__.py @@ -0,0 +1,9 @@ +from .agent import QuotaAwareAgent, Report, Step +from .budget import Balance, BudgetGuard, Change, Plan, estimate +from .client import HttpTransport, QuotaExhausted, SuperDocsClient, parse_proposed_changes + +__all__ = [ + "QuotaAwareAgent", "Report", "Step", + "Balance", "BudgetGuard", "Change", "Plan", "estimate", + "HttpTransport", "QuotaExhausted", "SuperDocsClient", "parse_proposed_changes", +] diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/agent.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/agent.py new file mode 100644 index 00000000..7360057d --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/agent.py @@ -0,0 +1,534 @@ +"""An agent that checks its allowance before planning, sizes the work to fit, +and degrades in plain language instead of failing halfway. + +The shape of the problem, from the docs rather than from optimism: + + You cannot read your balance on demand. `/v1/users/me/usage` and `/limits` + reject `sk_` keys with a 401. The agent-key path is `GET /v1/agents/whoami` + once at the start, and after that the `usage` block that rides on every chat + response. So the balance is authoritative at the start and after each call, + and an *estimate* in between. `Balance.authoritative` carries that + distinction so a report can never present a stale number as a live read. + +The consequence worth naming: this agent does not "check remaining allowance" +continuously. It reads it, plans against it, and re-reads it as a side effect +of every call it makes. That is the strongest guarantee the platform allows, +and the report says which number it used. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .budget import Balance, BudgetGuard, Change, Plan, estimate +from .client import QuotaExhausted, SuperDocsClient, provably_never_sent +from .idempotency import OperationLedger, State, operation_key +from .policy import Policy, StopReason, WhenItDoesNotFit +from .receipt import Receipt + + +@dataclass +class Step: + """One unit of requested work: an edit instruction plus how many document + sections it is expected to touch. Sections are what SuperDocs bills on -- + one operation per 25 sections edited -- so the plan is priced in sections + and reported in operations.""" + step_id: str + instruction: str + sections: int + severity: str = "medium" + + def as_change(self) -> Change: + return Change(row_id=self.step_id, sections=self.sections, severity=self.severity) + + +@dataclass +class Report: + """What the agent did, in the words it would say to a person.""" + balance_at_start: Balance | None = None + balance_at_end: Balance | None = None + planned: list[str] = field(default_factory=list) + completed: list[str] = field(default_factory=list) + deferred: list[str] = field(default_factory=list) + #: Steps an earlier run already paid for. Not repeated, and not billed. + already_applied: list[str] = field(default_factory=list) + #: Steps an earlier run started and whose outcome nobody ever learned. + #: Neither repeated nor forgotten -- a person has to look at the document. + needs_a_person: list[str] = field(default_factory=list) + ops_spent: int = 0 + stopped_because: str = "" + stop_reason: StopReason = StopReason.COMPLETED + export_warnings: list = field(default_factory=list) + lines: list[str] = field(default_factory=list) + receipt: Receipt = field(default_factory=Receipt) + + def say(self, line: str) -> None: + self.lines.append(line) + + def stop(self, reason: StopReason, line: str = "") -> None: + """Stopping is a rule firing, not a number running out. Recording which + rule is what lets somebody argue with it instead of raising it.""" + self.stop_reason = reason + self.stopped_because = reason.explain() + if line: + self.say(line) + + def reconciliation(self) -> str: + """Whether the line items agree with the allowance. Named, not hidden.""" + return self.receipt.reconcile(self.balance_at_start, + self.balance_at_end).explanation + + def plain_language(self) -> str: + out = list(self.lines) + if self.already_applied: + out.append( + f"Already done by an earlier run, and not repeated: " + f"{', '.join(self.already_applied)}. You were not charged again." + ) + if self.needs_a_person: + out.append( + f"Started by an earlier run and never confirmed: " + f"{', '.join(self.needs_a_person)}. These were not retried, " + "because retrying might be charged twice and might apply the " + "same edit twice. Open the document and check them." + ) + if self.deferred: + out.append( + f"Left undone: {', '.join(self.deferred)}. " + "These were not started, so nothing is half-applied." + ) + if self.balance_at_end: + out.append(f"Allowance now: {self.balance_at_end}.") + return "\n".join(out) + + +class QuotaAwareAgent: + """Reads the allowance, plans to fit it, and stops on the platform's signal. + + The stopping rule, stated once so it is not scattered: + 1. `quota_exhausted` from any response -- the platform's own signal, and + the only authoritative one. Stop immediately. + 2. The reserve floor -- never spend the last `reserve` operations, so + there is always enough left to export the work already done. + 3. `max_steps` -- the small-sample bound. Loops need a stopping rule. + """ + + def __init__(self, client: SuperDocsClient, guard: BudgetGuard | None = None, + policy: Policy | None = None, + ledger: OperationLedger | None = None) -> None: + self._c = client + self._g = guard or BudgetGuard() + self._p = policy or Policy() + # In memory unless a caller hands over a path. A ledger that forgets + # when the process dies is no use for the failure it exists for, so the + # CLI and the MCP server both give it a file. + self._ledger = ledger or OperationLedger() + + @property + def policy(self) -> Policy: + return self._p + + def unresolved_operations(self) -> list[dict]: + """Calls a previous run started and never learned the outcome of. + + Surfaced, never cleaned up. Deciding between "pay again" and "leave the + work undone" is somebody's money and somebody's document, so it is + reported and a person resolves it with `ledger.resolve`. + """ + return [{"key": r.key, "step_id": r.step_id, "session_id": r.session_id} + for r in self._ledger.unresolved()] + + def budget_hint(self) -> dict: + """The allowance, in a shape a calling agent can plan against. + + An underused technique in the cost-governance literature and an obvious + one once stated: an agent that knows what is left can decide earlier, + summarise instead of re-reading, or escalate before it runs out. This + rides back on every tool result rather than needing its own call -- + which matters here, because a call to find out is not free everywhere + and asking is itself a decision an agent should not have to make. + """ + balance = self._g.remaining() + return { + "remaining_operations": balance.ops, + "authoritative": balance.authoritative, + "as_of": balance.as_of, + "reserve": self._p.reserve, + "spendable_on_new_edits": self._p.spendable(balance.ops), + "exhausted": self._g.exhausted, + "policy": self._p.describe(), + "note": ( + "Authoritative at whoami and after every response that carried a " + "usage block; inferred in between, and it says which. Exports " + "are free, which is why the reserve costs you nothing." + ), + } + + def settled(self, session_id: str, steps: list[Step]) -> tuple[ + list[Step], list[str], list[str]]: + """Split requested work by what an earlier run already did with it. + + Returns (still to do, already applied, started and never confirmed). + + This exists so a plan and a run cannot disagree. Pricing work that an + earlier run already paid for quotes a number nobody will be charged, + and an agent deciding what it can afford against that number is being + told the wrong thing by the tool whose entire job is telling it the + right thing. + """ + to_do: list[Step] = [] + applied: list[str] = [] + unconfirmed: list[str] = [] + for step in steps: + record = self._ledger.get(operation_key( + session_id, step.step_id, step.instruction, step.sections)) + if record.state is State.APPLIED: + applied.append(step.step_id) + elif record.state is State.IN_FLIGHT: + unconfirmed.append(step.step_id) + else: + to_do.append(step) + return to_do, applied, unconfirmed + + # -- planning --------------------------------------------------------- + def read_allowance(self) -> Balance: + """The one moment the number is authoritative before any work begins.""" + r = self._c.whoami() + if r.usage.get("quota_exhausted") or getattr(self._c, "quota_exhausted", False): + # whoami is free and is not refused by exhaustion, but the signal it + # carries is the authoritative one and has to reach the planner. + self._g.mark_exhausted() + quota = r.body.get("quota", {}) or {} + remaining = quota.get("remaining") + if remaining is None: + # Never invent a balance. An unknown allowance is planned as zero, + # which degrades to doing nothing and saying why. + return self._g.seed_from_whoami(0) + return self._g.seed_from_whoami(int(remaining), as_of=str(quota.get("resets_at", ""))) + + #: Each step is its own `POST /v1/chat/async`, so each one bills at least + #: one operation. Pooling their sections and dividing by 25 -- which is + #: right for a publisher that sends one request -- under-prices an agent + #: that sends several, and an under-priced plan is an agent starting work + #: it cannot finish. Named here so the planner and `_run_step` cannot drift + #: apart about what a step costs. + BATCHED = False + + def plan(self, steps: list[Step]) -> Plan: + """Price the work and fit it to what is left, under the policy.""" + budget = self._p.spendable(self._g.remaining().ops) + steps, _bit = self._p.bound(steps) + plan = self._g.fit([s.as_change() for s in steps], remaining=budget, + batched=self.BATCHED) + if (plan.publish and plan.defer + and self._p.when_it_does_not_fit is WhenItDoesNotFit.REFUSE): + # A caller who would rather have nothing than a subset said so. + return Plan(publish=[], defer=plan.publish + plan.defer, + rationale=StopReason.REFUSED_PARTIAL.explain(), + batched=self.BATCHED) + return plan + + # -- execution -------------------------------------------------------- + def run(self, session_id: str, filename: str, content: bytes, steps: list[Step], + export_format: str = "docx") -> Report: + """Read the allowance, size the work, do what fits, and say what it did. + + The order is the load-bearing part and it is why this reads as four + steps rather than one block: the allowance is read *before* anything is + planned, the plan is priced *before* anything is uploaded, and the export + runs even when the run stopped early — because it is free, which is what + the reserve exists to guarantee. + """ + report = Report(receipt=Receipt(session_id=session_id)) + + start = self._read_and_report_allowance(report) + steps = self._apply_sample_bound(steps, report) + # Priced only over work nobody has paid for yet. What an earlier run + # settled is named in the report, not quoted as a cost. + steps = self._set_aside_what_earlier_runs_settled(session_id, steps, report) + if not steps and (report.already_applied or report.needs_a_person): + # Nothing left to do because an earlier run did it, not because it + # would not fit. Reporting that as "nothing fits" would send a + # caller off to buy allowance it does not need. + return self._nothing_left_to_do(session_id, export_format, start, report) + plan = self._price(steps, report) + if not plan.publish: + return self._did_not_start(plan, report) + + # Free per the docs, so it is not priced -- and it happens only after + # everything that could refuse has refused. + self._c.upload(session_id, filename, content) + report.say(f"Uploaded {filename}.") + + self._work(session_id, plan, {s.step_id: s for s in steps}, report) + return self._export(session_id, export_format, start, report) + + # -- the steps of a run, each one its own decision ---------------------- + def _read_and_report_allowance(self, report: Report) -> Balance: + start = self.read_allowance() + report.balance_at_start = start + report.say(f"Starting allowance: {start}.") + return start + + def _apply_sample_bound(self, steps: list[Step], report: Report) -> list[Step]: + bounded, bit = self._p.bound(steps) + if bit: + report.say( + f"Small-sample mode: running {self._p.max_steps} of {len(steps)} " + "requested steps." + ) + return bounded + + def _set_aside_what_earlier_runs_settled( + self, session_id: str, steps: list[Step], report: Report) -> list[Step]: + to_do, applied, unconfirmed = self.settled(session_id, steps) + for step_id in applied: + report.already_applied.append(step_id) + report.say(f"'{step_id}': an earlier run already applied this. " + "Not repeated, and not billed again.") + for step_id in unconfirmed: + report.needs_a_person.append(step_id) + report.say( + f"'{step_id}': an earlier run started this and never learned " + "whether it finished. Not retried — that might be charged twice " + "and might apply the same edit twice.") + return to_do + + def _price(self, steps: list[Step], report: Report) -> Plan: + plan = self.plan(steps) + report.planned = [c.row_id for c in plan.publish] + report.deferred = [c.row_id for c in plan.defer] + needed = estimate([s.as_change() for s in steps], batched=self.BATCHED) + report.say( + f"The full request would cost about {needed} operation(s). " + + ("It fits." if plan.complete else plan.rationale) + ) + return plan + + def _nothing_left_to_do(self, session_id: str, export_format: str, + start: Balance, report: Report) -> Report: + """Every requested step was settled by an earlier run. + + Nothing is billed, and the export still runs, because the point of a + rerun after a crash is to walk away with the file. + """ + report.stop( + StopReason.STARTED_AND_UNKNOWN if report.needs_a_person + else StopReason.ALREADY_APPLIED, + "Nothing was sent: every requested step was settled by an earlier " + "run. You were not charged again.", + ) + try: + return self._export(session_id, export_format, start, report) + except Exception as e: + # The session may not exist on this key any more. Say which, + # rather than turning a rerun into a crash. + report.say( + f"The export could not be made for session '{session_id}': {e}. " + "Nothing was billed. Open the session in SuperDocs and export " + "from there.") + report.balance_at_end = self._g.remaining() + return report + + def _did_not_start(self, plan: Plan, report: Report) -> Report: + """Nothing uploaded, nothing billed, and no half-edited document.""" + refused = plan.rationale == StopReason.REFUSED_PARTIAL.explain() + report.stop( + StopReason.REFUSED_PARTIAL if refused else StopReason.NOTHING_FITS, + "Did not start: " + + ("only part of the work fits, and this policy refuses a partial " + "run rather than deliver a subset." + if refused else + "none of the requested work fits inside the remaining allowance.") + + " Nothing was uploaded and nothing was billed.", + ) + report.balance_at_end = self._g.remaining() + return report + + def _work(self, session_id: str, plan: Plan, by_id: dict[str, Step], + report: Report) -> None: + """Each fitted step: instruct, wait, approve — unless it is already done.""" + for change in plan.publish: + step = by_id[change.row_id] + key = operation_key(session_id, step.step_id, step.instruction, + step.sections) + if self._already_settled(key, step, report): + continue + + try: + self._run_step(session_id, step, report, key) + except QuotaExhausted: + report.stop(StopReason.QUOTA_EXHAUSTED) + report.deferred.append(step.step_id) + report.say( + f"Stopped during '{step.step_id}': SuperDocs reported the " + "allowance exhausted. That request still completed; nothing " + "further was attempted." + ) + return + if self._reserve_reached(plan, step, report): + return + + def _already_settled(self, key: str, step: Step, report: Report) -> bool: + """Has this exact call already been paid for, or already been sent? + + SuperDocs has no idempotency on billable writes, so a rerun after a + crash would otherwise be charged again for work already in the document. + """ + prior = self._ledger.get(key) + if prior.state is State.APPLIED: + report.already_applied.append(step.step_id) + report.say( + f"'{step.step_id}': an earlier run already applied this. " + "Not repeated, and not billed again." + ) + return True + if prior.state is State.IN_FLIGHT: + report.needs_a_person.append(step.step_id) + report.say( + f"'{step.step_id}': an earlier run started this and never " + "learned whether it finished. Not retried — that might be " + "charged twice and might apply the same edit twice." + ) + return True + return False + + def _reserve_reached(self, plan: Plan, step: Step, report: Report) -> bool: + if self._g.remaining().ops > self._p.reserve: + return False + remaining_ids = [ + c.row_id for c in plan.publish + if c.row_id not in report.completed and c.row_id != step.step_id + ] + if remaining_ids: + report.stop(StopReason.RESERVE_FLOOR) + report.deferred.extend(remaining_ids) + report.say( + f"Stopped after '{step.step_id}': holding back the last " + f"{self._p.reserve} operation(s) so the work already done " + "can still be exported." + ) + return True + + def _export(self, session_id: str, export_format: str, start: Balance, + report: Report) -> Report: + """Free, so it always runs — including after stopping early. That is the + whole reason the reserve is worth holding.""" + exported = self._c.export(session_id, export_format) + report.export_warnings = self._c.export_warnings(exported) + report.say(f"Exported the document as {export_format} (exports are free).") + if report.export_warnings: + report.say( + f"The export completed with {len(report.export_warnings)} " + "non-fatal warning(s), listed in the report -- the file is usable." + ) + report.balance_at_end = self._g.remaining() + report.ops_spent = max(0, (start.ops if start else 0) - report.balance_at_end.ops) + return report + + def _run_step(self, session_id: str, step: Step, report: Report, + key: str | None = None) -> None: + key = key or operation_key(session_id, step.step_id, step.instruction, + step.sections) + # Written BEFORE the call goes out. If the process dies between these + # two lines the ledger says "started, outcome unknown", which is the + # truth; writing it afterwards would leave no trace of a call that was + # charged. + self._ledger.begin(key, session_id=session_id, step_id=step.step_id) + try: + started = self._c.edit(session_id, step.instruction) + except Exception as e: + # Which of these two it is decides whether a rerun repeats the call. + # "It failed" is not enough information to answer that, so it is + # never the answer recorded: only a failure that PROVES the request + # never reached SuperDocs is marked repeatable. Everything else -- + # a read timeout, a 5xx from a gateway, a dropped connection -- may + # have been accepted and billed, and is recorded as started and + # unconfirmed so a person looks at it instead of a retry paying + # for it twice. + if provably_never_sent(e): + self._ledger.failed( + key, f"the edit call was rejected before it was billed: {e}") + else: + self._ledger.never_learned( + key, f"the edit call did not return, and may have been " + f"accepted and billed: {e}") + raise + self._reconcile(started, report, billable_ops=estimate([step.as_change()]), + step_id=step.step_id, call="edit") + job_id = started.body.get("job_id") + if not job_id: + self._ledger.applied(key, note="no job id returned") + report.say(f"'{step.step_id}': no job id returned; nothing applied.") + return + + waited = self._c.poll_job( + job_id, + on_wait=lambda s, status: report.say( + f"'{step.step_id}': still {status} after {s:.0f}s -- processing, not stalled." + ) if s and s % 60 == 0 else None, + ) + self._reconcile(waited, report, step_id=step.step_id, call="poll") + + if waited.body.get("status") == "awaiting_approval": + changes = self._pending(waited) + decisions = [{"change_id": c.get("change_id"), "approved": True} for c in changes] + if decisions: + approved = self._c.approve(session_id, job_id, decisions) + self._reconcile(approved, report, step_id=step.step_id, + call="approve") + report.say(f"'{step.step_id}': approved {len(decisions)} proposed change(s).") + + # Approval is ASYNCHRONOUS. The approve call returns ok, then + # the job resumes and applies the change. Exporting before it + # reaches a terminal state returns the document as it was -- + # HTTP 200, a valid file, and the edit silently missing. Verified + # against the live API on 2026-08-19. + settled = self._c.poll_job(job_id, deadline_s=300, interval_s=2) + self._reconcile(settled, report, step_id=step.step_id, + call="poll") + if settled.body.get("status") != "completed": + report.say( + f"'{step.step_id}': the job ended as " + f"'{settled.body.get('status')}' rather than completed; the " + "approved change may not have been applied." + ) + self._ledger.applied(key, job_id=str(job_id)) + report.completed.append(step.step_id) + + @staticmethod + def _pending(job: dict | object) -> list[dict]: + """One implementation, in the client, shared by both builds.""" + from .client import pending_changes + + return pending_changes(job.body if hasattr(job, "body") else job) + + def _reconcile(self, response, report: Report, billable_ops: int = 0, + step_id: str = "", call: str = "") -> None: + usage = response.usage + if not usage: + # A billable call that returned no usage block leaves us guessing. + # Guess, and say that we are guessing. + if billable_ops: + self._g.assume_spent(billable_ops) + if call: + report.receipt.record( + step_id, call, billable=bool(billable_ops), + ops_charged=None, ops_estimated=billable_ops, + balance=self._g.remaining(), + note=("no usage block came back, so this line is what we " + "believe rather than what was stated") + if billable_ops else "", + ) + return + self._g.reconcile( + ops_charged=int(usage.get("ops_charged", 0)), + monthly_remaining=usage.get("monthly_remaining"), + quota_exhausted=bool(usage.get("quota_exhausted", False)), + ) + if call: + report.receipt.record( + step_id, call, billable=bool(billable_ops), + ops_charged=int(usage.get("ops_charged", 0)), + ops_estimated=billable_ops, balance=self._g.remaining(), + ) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/budget.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/budget.py new file mode 100644 index 00000000..773be111 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/budget.py @@ -0,0 +1,213 @@ +"""BudgetGuard -- never start work you cannot finish. + +SHARED CORE. This module is used unchanged by another system by the same +author, where it guards the publisher that writes documents back to SuperDocs. +It is vendored here rather than imported so this build stands alone in its own +repository. Reuse is only a shortcut when it is hidden, so it is stated here and +in the README. + +Grounded in the SuperDocs documentation rather than in the task brief +(TASK.md rule 1). Two facts from the docs shape this module: + + * Most requests bill one operation; very large ones bill one per 25 sections + edited. So pricing is done in sections and reported in operations. + * The usage endpoints reject API keys -- `/v1/users/me/usage` and `/limits` + accept web-app session tokens only. From an API-key context the balance is + read off the `usage` block that rides on every chat response. You therefore + learn your balance as a side effect of doing work, not in advance of it. + +That second point is why `Balance` carries `authoritative`. Between calls the +number is an estimate and says so. `quota_exhausted` on a response is the +authoritative stop signal. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +SECTIONS_PER_OP = 25 + + +@dataclass(frozen=True) +class Balance: + ops: int + authoritative: bool + as_of: str = "" + + def __str__(self) -> str: + qualifier = "confirmed" if self.authoritative else "estimated" + unit = "operation" if self.ops == 1 else "operations" + return f"{self.ops} {unit} ({qualifier})" + + +@dataclass(frozen=True) +class Change: + row_id: str + sections: int + severity: str = "medium" + + +_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} + + +@dataclass +class Plan: + publish: list[Change] = field(default_factory=list) + defer: list[Change] = field(default_factory=list) + rationale: str = "" + #: How the published work will be sent -- one request, or one each. Carried + #: on the plan so a price quoted at planning time and the price charged at + #: run time cannot come from two different models of the same work. + batched: bool = True + + @property + def ops_to_publish(self) -> int: + return estimate(self.publish, batched=self.batched) + + @property + def complete(self) -> bool: + return not self.defer + + +def estimate(changes: list[Change], *, batched: bool = True) -> int: + """Operations a set of changes will bill. + + Zero changes cost nothing. Anything else costs at least one operation, + then one more per 25 sections -- which is the documented accounting, not + a division. + + `batched` is the question the docs force and that a section count alone + cannot answer: **do these changes ride in one request, or in several?** + The docs price a *request* -- "most requests bill one operation; very large + ones bill one per 25 sections edited" -- so the floor of one applies per + request, not per plan. + + * ``batched=True`` -- all of it goes in a single `POST /v1/chat/async`. + That is the publisher's shape, where a document write is one call. + * ``batched=False`` -- each change is its own call. That is the agent's + shape: one edit instruction per step, one request each. + + Getting this wrong is not a rounding error. Four steps of five sections + pooled to twenty sections price as ONE operation, and then bill as FOUR -- + so the agent reports "it fits", starts, and runs out partway through + somebody's document. That is the exact failure this build exists to + prevent, arriving through its own arithmetic. + """ + if not batched: + return sum(estimate([c]) for c in changes) + sections = sum(c.sections for c in changes) + if sections <= 0: + return 0 + return max(1, math.ceil(sections / SECTIONS_PER_OP)) + + +class BudgetGuard: + def __init__(self, seed: Balance | None = None) -> None: + self._balance = seed or Balance(ops=0, authoritative=False) + self._exhausted = False + + def seed_from_whoami(self, remaining_ops: int, as_of: str = "") -> Balance: + """The agent whoami call does accept an agent key, so this is the one + moment the balance is genuinely authoritative before work begins.""" + self._balance = Balance(remaining_ops, authoritative=True, as_of=as_of) + return self._balance + + def assume_spent(self, ops: int) -> Balance: + """No usage block came back on a call we believe was billable. + + The docs say usage rides on every chat response; the async endpoints + empirically return none (verified 2026-08-19). So the balance is + decremented by our own estimate and, crucially, **stops being + authoritative** -- continuing to print "confirmed" over a number the + platform never confirmed is the exact bluff `authoritative` exists to + prevent. + """ + self._balance = Balance( + max(0, self._balance.ops - max(0, ops)), authoritative=False, + as_of=self._balance.as_of, + ) + return self._balance + + def reconcile(self, ops_charged: int, monthly_remaining: int | None, quota_exhausted: bool, + as_of: str = "") -> Balance: + """Called with the `usage` block from every response.""" + self._exhausted = quota_exhausted + if monthly_remaining is not None: + self._balance = Balance(monthly_remaining, authoritative=True, as_of=as_of) + else: + self._balance = Balance( + max(0, self._balance.ops - ops_charged), authoritative=False, as_of=as_of + ) + return self._balance + + def mark_exhausted(self) -> None: + """The platform said so on a call that was allowed to complete anyway. + + Free calls are not refused when the allowance is gone, but the signal + they carry is still the authoritative one and must reach the planner -- + otherwise the agent reads "exhausted" and plans as though it had not. + """ + self._exhausted = True + + def remaining(self) -> Balance: + return self._balance + + @property + def exhausted(self) -> bool: + return self._exhausted + + def fit(self, changes: list[Change], remaining: int | None = None, + *, batched: bool = True) -> Plan: + """Pure given a budget number. Publishes what fits, highest severity + first, defers the rest, and says so in a sentence a person can read. + + `batched` says whether the published set is one request or one each; + see `estimate`. It is threaded through rather than defaulted quietly, + because a plan priced under the wrong model is a plan that fits on + paper and overruns in practice. + """ + budget = self._balance.ops if remaining is None else remaining + needed = estimate(changes, batched=batched) + + if self._exhausted or budget <= 0: + # These are different situations and must not be reported as one. + # "Exhausted" is the platform's own signal; a zero budget can also + # mean a reserve is being held back so finished work stays usable. + reason = ( + "the allowance is exhausted" + if self._exhausted + else "there are no operations available to spend" + ) + return Plan( + publish=[], + defer=list(changes), + rationale=( + f"Started nothing: {reason}. " + f"{len(changes)} change(s) are queued and named in the run report." + ), + batched=batched, + ) + + if needed <= budget: + return Plan(publish=list(changes), defer=[], rationale="", batched=batched) + + ordered = sorted( + changes, key=lambda c: (_SEVERITY_ORDER.get(c.severity, 99), -c.sections) + ) + publish: list[Change] = [] + for change in ordered: + if estimate(publish + [change], batched=batched) <= budget: + publish.append(change) + deferred = [c for c in changes if c not in publish] + + pub_sections = sum(c.sections for c in publish) + all_sections = sum(c.sections for c in changes) + rationale = ( + f"Sized to fit: {pub_sections} of {all_sections} changed sections " + f"({estimate(publish, batched=batched)} of {needed} operations). Deferred " + f"{len(deferred)} lower-severity change(s) to stay inside the " + "remaining allowance." + ) + return Plan(publish=publish, defer=deferred, rationale=rationale, + batched=batched) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/client.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/client.py new file mode 100644 index 00000000..4138d6fe --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/client.py @@ -0,0 +1,425 @@ +"""The four-call contract: upload, edit instruction, approve, export. + +Everything here is grounded in the SuperDocs documentation, and the three traps +the docs name by hand are handled explicitly rather than discovered later: + + Trap 1 -- the double parse. A `proposed_change_batch` envelope carries its + payload as a JSON-encoded *string* in `content`. `parse_proposed_changes` + does the second parse. Skipping it is the documented single most common + reason integrators see empty diff cards with every field undefined. + + Trap 2 -- silence is not a crash. A job on a large document can run for + minutes with no visible progress. `poll_job` treats a long quiet run as + still processing and only gives up at an explicit deadline, reporting how + long it waited rather than calling it a failure. + + Trap 3 -- the allowance. Every response carries a `usage` block; nothing here + spends without handing that block back to the caller to reconcile. + +Transport is injected. `HttpTransport` is the real one; the tests pass a fake, +which is why this file has no network dependency and the suite needs no key. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Protocol + +BASE = "https://api.superdocs.app" + +# Terminal and non-terminal job states, from the docs' job lifecycle. +_TERMINAL = {"completed", "failed", "cancelled"} +_NEEDS_HUMAN = "awaiting_approval" + + +class Transport(Protocol): + def request(self, method: str, path: str, **kw: Any) -> "Response": ... + + +@dataclass +class Response: + status: int + body: dict + headers: dict = field(default_factory=dict) + + @property + def usage(self) -> dict: + """The usage block rides on every chat response. It is the only way to + read the balance from an API-key context -- the account usage endpoints + reject `sk_` keys with a 401.""" + return self.body.get("usage", {}) or {} + + +class SuperDocsError(RuntimeError): + def __init__(self, status: int, body: Any) -> None: + super().__init__(f"SuperDocs returned {status}: {body}") + self.status = status + self.body = body + + +class QuotaExhausted(SuperDocsError): + """Raised only when the platform says so. Never inferred from our own count.""" + + +class TransportFailure(RuntimeError): + """The call did not produce a response, and we have to say which kind. + + The distinction is the whole point. A connection that was refused means the + request never reached SuperDocs and cannot have been billed, so a rerun may + safely repeat it. A read that timed out means the request very possibly did + reach SuperDocs, was charged, and applied an edit -- we simply never heard + the answer. Collapsing the two into "it failed" is how a retry pays twice. + """ + + def __init__(self, message: str, *, never_sent: bool) -> None: + super().__init__(message) + self.never_sent = never_sent + + +def provably_never_sent(exc: BaseException) -> bool: + """True only when the request cannot have been billed. + + Deliberately conservative: the default answer is "we do not know", because + the cost of wrongly believing a call was billed is one step reported to a + person, and the cost of wrongly believing it was not is the user paying + twice and possibly getting the same edit applied twice. + """ + import socket + + if isinstance(exc, TransportFailure): + return exc.never_sent + if isinstance(exc, QuotaExhausted): + # The request that carried this signal completed; it was answered. + return False + if isinstance(exc, SuperDocsError): + # A 4xx was rejected before any work happened, so it was not billed. + # A 5xx may have come from a gateway that had already passed the + # request on, so it proves nothing. + return exc.status < 500 + reason = getattr(exc, "reason", exc) + return isinstance(reason, (ConnectionRefusedError, socket.gaierror)) + + +def _encode_multipart(files: dict, fields: dict) -> tuple[bytes, str]: + """Build a multipart/form-data body from {name: (filename, bytes)} plus + plain fields. Returns (body, content_type). + + A fixed boundary would collide with content that happens to contain it, so + it is derived from the payload -- deterministic for a given body, which + keeps requests reproducible, and vanishingly unlikely to appear inside it. + """ + import hashlib + + digest = hashlib.sha256() + for name, (filename, content) in sorted(files.items()): + digest.update(name.encode()) + digest.update(str(filename).encode()) + digest.update(content if isinstance(content, bytes) else str(content).encode()) + boundary = "----formdata" + digest.hexdigest()[:24] + + out = bytearray() + for name, value in fields.items(): + out += f"--{boundary}\r\n".encode() + out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + out += str(value).encode("utf-8") + b"\r\n" + for name, (filename, content) in files.items(): + if isinstance(content, str): + content = content.encode("utf-8") + out += f"--{boundary}\r\n".encode() + out += (f'Content-Disposition: form-data; name="{name}"; ' + f'filename="{filename}"\r\n').encode() + out += f"Content-Type: {_guess_type(filename)}\r\n\r\n".encode() + out += content + b"\r\n" + out += f"--{boundary}--\r\n".encode() + return bytes(out), f"multipart/form-data; boundary={boundary}" + + +def _guess_type(filename: str) -> str: + import mimetypes + + return mimetypes.guess_type(str(filename))[0] or "application/octet-stream" + + +class HttpTransport: + """Real transport. Imported lazily so the package needs no HTTP library + installed to run its tests.""" + + def __init__(self, api_key: str, base: str = BASE, timeout: float = 300.0) -> None: + self._key = api_key + self._base = base + # ~300s is the platform gateway timeout for synchronous requests. + self._timeout = timeout + + def request(self, method: str, path: str, **kw: Any) -> Response: + import urllib.error + import urllib.request + + url = self._base + path + headers = {"Authorization": f"Bearer {self._key}"} + data = None + if "files" in kw: + # Upload is multipart/form-data, not JSON. Encoded here rather than + # with a library because this package has no dependencies -- and + # because getting it wrong is invisible: the request still sends, + # and the API answers 422 for a field it never received. + data, content_type = _encode_multipart(kw["files"], kw.get("data", {})) + headers["Content-Type"] = content_type + elif "json" in kw: + data = json.dumps(kw["json"]).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as r: + raw = r.read() + body = json.loads(raw) if raw and r.headers.get_content_type() == "application/json" else {"raw": raw} + return Response(r.status, body, dict(r.headers)) + except urllib.error.HTTPError as e: + raw = e.read() + try: + body = json.loads(raw) + except Exception: + body = {"raw": raw.decode(errors="replace")} + return Response(e.code, body, dict(e.headers or {})) + except urllib.error.URLError as e: + # Name the cause and the fix, and -- more importantly -- say whether + # the request could have been billed, so the ledger can record the + # truth rather than the convenient answer. + never_sent = provably_never_sent(e) + raise TransportFailure( + f"could not reach {self._base} ({e.reason}). " + + ("The connection was refused or the host did not resolve, so " + "the request never reached SuperDocs and was not billed — " + "check network access to api.superdocs.app and retry." + if never_sent else + "It is not known whether the request arrived, so it must not " + "be assumed unbilled — rerun and read what the operation " + "ledger reports about it."), + never_sent=never_sent, + ) from e + except TimeoutError as e: + # A read timeout is the ambiguous case by definition: the request + # went out and the answer never came back. + raise TransportFailure( + f"no response from {self._base} within {self._timeout:.0f}s. " + "SuperDocs may still be processing this — large documents and " + "the deepest model settings take minutes — so the request may " + "well have been accepted and billed. It is recorded as started " + "and unconfirmed rather than retried.", + never_sent=False, + ) from e + + +#: What the upload endpoint parses each extension as. Verified against the live +#: API 2026-08-20: the **filename decides the parser**, not the bytes. HTML sent +#: as `report.docx` is answered `400 Invalid DOCX file: File is not a zip file`, +#: and the same bytes as `report.html` are accepted. `.txt` is accepted too and +#: parses the markup as literal text, which is worse than an error because it +#: succeeds. +_ZIP_EXTENSIONS = {".docx", ".xlsx", ".pptx", ".odt"} +_PDF_EXTENSIONS = {".pdf"} +_TEXT_EXTENSIONS = {".html", ".htm", ".txt", ".md", ".markdown", ".rtf"} + + +def check_upload_name(filename: str, content: bytes) -> None: + """Refuse a filename whose extension disagrees with the bytes. + + This is a deliberate hardcoded defence sitting in front of the intelligent + path, not a guess about what the caller meant. The live API decides how to + parse an upload from the extension alone, so `agent.run(..., "contract.docx", + html_bytes)` reads perfectly and fails at the platform with a message about + zip files, which names neither the cause nor the fix. Worse, the mismatch + that does NOT error — HTML uploaded as `.txt` — succeeds and quietly parses + the markup as literal text, and nobody finds out until the export. + + So it is checked here, before anything is sent, and the error says which + two things disagreed and both ways to make them agree. + """ + import os + + ext = os.path.splitext(str(filename))[1].lower() + if not ext: + raise ValueError( + f"'{filename}' has no file extension. SuperDocs chooses how to parse " + "an upload from the extension, so give one — '.html' for HTML, " + "'.docx' for a Word file, '.pdf' for a PDF.") + + looks_like_zip = content[:4] == b"PK\x03\x04" + looks_like_pdf = content[:4] == b"%PDF" + + if ext in _ZIP_EXTENSIONS and not looks_like_zip: + raise ValueError( + f"'{filename}' is named as a Word-family file but the bytes are not " + "a zip archive, and SuperDocs parses uploads by extension — it would " + "answer '400 Invalid DOCX file: File is not a zip file'. Either send " + "the real .docx bytes, or rename this to '.html' if it is HTML.") + if ext in _PDF_EXTENSIONS and not looks_like_pdf: + raise ValueError( + f"'{filename}' is named as a PDF but the bytes do not begin with " + "'%PDF'. Send the real PDF bytes, or rename it to match what it is.") + if ext in _TEXT_EXTENSIONS and (looks_like_zip or looks_like_pdf): + raise ValueError( + f"'{filename}' is named as text but the bytes are a " + f"{'zip archive (a .docx, most likely)' if looks_like_zip else 'PDF'}. " + "This would be accepted and parsed as literal text rather than as a " + "document — rename it to match its contents.") + + +def pending_changes(job_body: dict) -> list[dict]: + """Read the proposed changes off a job, whatever shape they arrive in. + + Two shapes exist and both are real: + * `GET /v1/jobs/{id}` returns `metadata.pending_changes` as a plain LIST + of change dicts. Verified against the live API 2026-08-19. + * The SSE `proposed_change_batch` event delivers an envelope whose + `content` is a JSON-encoded STRING needing a second parse. + + This lives in the client because both builds need it, and the copy that had + it written separately handled only the envelope -- so it crashed on the + shape the polling path actually returns. + """ + meta = job_body.get("metadata") or {} + pending = meta.get("pending_changes") + if pending is None: + for event in meta.get("intermediate_responses", []) or []: + if event.get("type") == "proposed_change_batch": + return parse_proposed_changes(event) + return [] + if isinstance(pending, list): + return list(pending) + if isinstance(pending, str): + return parse_proposed_changes({"content": pending}) + return parse_proposed_changes(pending) + + +def parse_proposed_changes(envelope: dict) -> list[dict]: + """Trap 1. The batch arrives as a JSON string inside `content`. + + A single-change turn still arrives as a one-element `changes[]`, so this + always returns a list and never special-cases the singular form. + """ + content = envelope.get("content") + if content is None: + return list(envelope.get("changes", [])) + batch = json.loads(content) if isinstance(content, str) else content + return list(batch.get("changes", [])) + + +class SuperDocsClient: + def __init__(self, transport: Transport, sleep: Callable[[float], None] = time.sleep) -> None: + self._t = transport + self._sleep = sleep + #: Set once the platform has said the allowance is exhausted, including + #: when it said so on a free call that was allowed to complete anyway. + self.quota_exhausted = False + + def _check(self, r: Response, *, billable: bool = True) -> Response: + """Raise on an error, and on the platform's own exhaustion signal. + + `billable=False` marks the free calls -- whoami and export. An exhausted + allowance must not stop those: exports and downloads never cost + operations, and the reserve exists precisely to promise that the work + already done can still be exported. Raising here would break that + promise at the exact moment it matters, turning "you always end up with + a file" into "you end up with a session and an exception". The signal is + still recorded, so the caller stops spending; it just does not block a + call that costs nothing. + """ + if r.status >= 400: + raise SuperDocsError(r.status, r.body) + if r.usage.get("quota_exhausted"): + # The current request still completed; further billable ones will not. + self.quota_exhausted = True + if billable: + raise QuotaExhausted(r.status, r.body) + return r + + # --- call 0: the one authoritative balance read available to an agent key. + def whoami(self) -> Response: + # Free, and it is the call that tells you the allowance is gone. Being + # refused by the exhaustion it exists to report would be absurd. + return self._check(self._t.request("GET", "/v1/agents/whoami"), + billable=False) + + # --- call 1 of the contract: upload. + def upload(self, session_id: str, filename: str, content: bytes) -> Response: + check_upload_name(filename, content) + return self._check( + self._t.request( + "POST", "/v1/documents/upload", + files={"file": (filename, content)}, data={"session_id": session_id}, + ) + ) + + # --- call 2: the edit instruction. + def edit(self, session_id: str, message: str, approval_mode: str = "ask_every_time") -> Response: + return self._check( + self._t.request( + "POST", "/v1/chat/async", + json={"session_id": session_id, "message": message, "approval_mode": approval_mode}, + ) + ) + + def job(self, job_id: str) -> Response: + return self._check(self._t.request("GET", f"/v1/jobs/{job_id}")) + + def poll_job(self, job_id: str, deadline_s: float = 600.0, interval_s: float = 2.0, + on_wait: Callable[[float, str], None] | None = None) -> Response: + """Trap 2. Silence is still processing. + + Returns as soon as the job is terminal *or* is waiting on a human. Gives + up only at an explicit deadline, and says how long it waited -- a slow + job is never reported as a crash. + """ + waited = 0.0 + while True: + r = self.job(job_id) + status = r.body.get("status", "") + if status in _TERMINAL or status == _NEEDS_HUMAN: + return r + if waited >= deadline_s: + raise TimeoutError( + f"job {job_id} was still '{status}' after {waited:.0f}s. " + "That is a deadline this client imposed, not a platform failure -- " + "the job may still be running." + ) + if on_wait: + on_wait(waited, status) + self._sleep(interval_s) + waited += interval_s + + # --- call 3: approve, item by item. + def approve(self, session_id: str, job_id: str, decisions: list[dict]) -> Response: + """`decisions` is a list of {change_id, approved, feedback?}. Sent as a + batch so a mixed approve/deny turn is one request, not one per change.""" + return self._check( + self._t.request( + "POST", f"/v1/chat/{session_id}/approve", + json={"job_id": job_id, "approved": True, "changes": decisions}, + ) + ) + + # --- call 4: export. Free, per the docs, and so never priced. + def export(self, session_id: str, fmt: str = "docx") -> Response: + """Free per the docs, so an exhausted allowance never blocks it.""" + return self._check( + self._t.request("POST", "/v1/documents/export", + json={"session_id": session_id, "format": fmt}), + billable=False, + ) + + @staticmethod + def export_warnings(r: Response) -> list: + """Exports can succeed with non-fatal issues, carried base64-encoded in + `X-Export-Warnings`. Surfaced rather than swallowed -- a dropped field + code is exactly the kind of thing a user should be told about.""" + import base64 + + header = r.headers.get("X-Export-Warnings") + if not header: + return [] + try: + return json.loads(base64.b64decode(header)) + except Exception: + return [] diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/idempotency.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/idempotency.py new file mode 100644 index 00000000..e65a9bec --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/idempotency.py @@ -0,0 +1,193 @@ +"""Not paying twice for the same edit after a run was interrupted. + +Grounded in the documentation rather than in optimism: **SuperDocs has no +idempotency on billable writes.** There is no idempotency key on +`POST /v1/chat/async`, so the same edit instruction sent twice is two +operations and, quite possibly, the same change applied twice. Nothing below us +prevents that, which means the only place it can be prevented is here. + +From the research, the failure this exists for, in the words of somebody it +happened to: *"I reran it from nineteen. But I got the boundary wrong — I think +I redid two or three that were already done. Paid for those twice."* + +Three states, and the third is the interesting one: + + ``applied`` the call completed and we saw it complete. Never repeated. + ``in flight`` we sent the call and the process died before we learned the + outcome. **Also never repeated** — and reported, because it + needs a person. Retrying might be charged twice and might + apply the same edit twice; skipping might leave the work + undone. There is no safe automatic answer, and inventing one + would be this build guessing with somebody's money. + ``unknown`` never attempted. Do it. + +The ledger is content-addressed on what the call *is* — session, step, +instruction and size — rather than on a caller-supplied id, because the caller +who got the boundary wrong was working from ids they assigned themselves. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from enum import Enum +from pathlib import Path + + +class State(str, Enum): + UNKNOWN = "unknown" + IN_FLIGHT = "in_flight" + APPLIED = "applied" + FAILED = "failed" + + +@dataclass(frozen=True) +class Record: + key: str + state: State + session_id: str = "" + step_id: str = "" + ops_charged: int | None = None + job_id: str = "" + note: str = "" + + @property + def repeatable(self) -> bool: + """Whether sending this call again is safe. + + `FAILED` is repeatable because a call that failed before it was accepted + was not charged. `IN_FLIGHT` is not, and that is the whole point. + """ + return self.state in (State.UNKNOWN, State.FAILED) + + +def operation_key(session_id: str, step_id: str, instruction: str, + sections: int) -> str: + """A stable name for one billable call. + + Over what the call *does*, so a rerun that renumbers its steps still + recognises work it already paid for. The session is included because the + same instruction against a different document is a different operation. + """ + h = hashlib.sha256() + for part in (session_id, step_id, instruction.strip(), str(sections)): + h.update(part.encode("utf-8")) + h.update(b"\x1f") + return h.hexdigest() + + +class OperationLedger: + """What this account has already been charged for, and what is unresolved. + + Backed by a file when given one, because the failure it exists for is a + process that died — a ledger that lives only in memory forgets exactly when + it is needed. Appended to rather than rewritten, so a second death during a + write cannot lose earlier lines. + """ + + def __init__(self, path: str | Path | None = None) -> None: + self._path = Path(path) if path else None + self._records: dict[str, Record] = {} + if self._path and self._path.exists(): + self._load() + + # -- reads ------------------------------------------------------------- + def get(self, key: str) -> Record: + return self._records.get(key, Record(key=key, state=State.UNKNOWN)) + + def records(self) -> list[Record]: + return list(self._records.values()) + + def unresolved(self) -> list[Record]: + """Calls that were started and whose outcome was never learned. + + These are what a person has to look at. They are surfaced rather than + cleaned up, because cleaning them up means choosing between paying twice + and leaving work undone, and that is not our choice to make. + """ + return [r for r in self._records.values() if r.state is State.IN_FLIGHT] + + # -- writes ------------------------------------------------------------ + def begin(self, key: str, *, session_id: str = "", step_id: str = "") -> Record: + """Written *before* the call goes out. That ordering is the guarantee. + + If it were written afterwards, a process that died mid-call would leave + no trace of the call at all, and the rerun would repeat it — which is + the exact failure this module exists to prevent. + """ + return self._put(Record(key=key, state=State.IN_FLIGHT, + session_id=session_id, step_id=step_id)) + + def applied(self, key: str, *, ops_charged: int | None = None, + job_id: str = "") -> Record: + prior = self.get(key) + return self._put(Record(key=key, state=State.APPLIED, + session_id=prior.session_id, step_id=prior.step_id, + ops_charged=ops_charged, job_id=job_id)) + + def failed(self, key: str, note: str = "") -> Record: + """The call was never accepted, so it was never charged. + + Only for failures that *prove* the request never reached the platform. + A failure that merely means we never heard the answer is a different + thing and must stay `IN_FLIGHT` -- see `never_learned`. + """ + prior = self.get(key) + return self._put(Record(key=key, state=State.FAILED, + session_id=prior.session_id, + step_id=prior.step_id, note=note)) + + def never_learned(self, key: str, note: str = "") -> Record: + """The call may have been accepted, and we never found out. + + A read timeout, a dropped connection mid-response, a 5xx from a gateway + that had already passed the request on: in every one of those the edit + may be applied and the operation may be charged. Marking them `FAILED` + makes them repeatable, and repeating them is the double-billing this + module exists to prevent -- so they stay `IN_FLIGHT` and are reported + to a person, exactly like a run that died mid-call. + """ + prior = self.get(key) + return self._put(Record( + key=key, state=State.IN_FLIGHT, session_id=prior.session_id, + step_id=prior.step_id, job_id=prior.job_id, + note=note or "the call was sent and its outcome was never learned", + )) + + def resolve(self, key: str, *, applied: bool, note: str = "") -> Record: + """A person looked at the document and told us what happened.""" + prior = self.get(key) + return self._put(Record( + key=key, state=State.APPLIED if applied else State.FAILED, + session_id=prior.session_id, step_id=prior.step_id, + job_id=prior.job_id, + note=note or "resolved by a person after an interrupted run", + )) + + # -- storage ----------------------------------------------------------- + def _put(self, record: Record) -> Record: + self._records[record.key] = record + if self._path: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({**asdict(record), + "state": record.state.value}) + "\n") + return record + + def _load(self) -> None: + for line in self._path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + raw["state"] = State(raw["state"]) + # Later lines win: the file is an append-only log, so the last + # word about a key is the current one. + self._records[raw["key"]] = Record(**raw) + except (ValueError, TypeError, KeyError): + # A half-written final line is what an interrupted run leaves + # behind. Skipping it loses nothing a complete line did not + # already say; refusing to open the file would lose everything. + continue diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/mcp_server.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/mcp_server.py new file mode 100644 index 00000000..fff31bba --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/mcp_server.py @@ -0,0 +1,472 @@ +"""The MCP surface. Band S1 says MCP, and agents are who this serves. + +Everything here is a thin wrapper over `QuotaAwareAgent`, which is already +tested. No planning logic lives in this file -- if it did, the MCP path and the +library path could disagree about what fits inside an allowance, and only one +of them would be tested. + +The tools mirror how an agent actually works: + + check_allowance -- what have I got? (free; the one authoritative read) + plan_work -- what fits inside it? (free; spends nothing, decides nothing) + run_work -- do the part that fits, and tell me what you left out. + resolve_operation -- a person's answer about a call that was started and never + confirmed. Free. Without it that step could never run again, + which is a state the surface could enter and not leave. + +`plan_work` exists as a separate tool on purpose. An agent that must commit to +work before it can find out what fits is the exact failure this build is about, +so the plan is readable without spending anything. +""" + +from __future__ import annotations + +import json +import os + +from .agent import QuotaAwareAgent, Step +from .budget import estimate +from .client import HttpTransport, SuperDocsClient +from .idempotency import OperationLedger, State, operation_key +from .policy import Policy, WhenItDoesNotFit + +SERVER_NAME = "quota-aware-agent" + +#: Where the operation ledger is kept between runs. It has to outlive the +#: process, because the failure it prevents -- paying twice for the same edit -- +#: is caused by a process that died. Overridable so two agents driving different +#: accounts do not share one. +LEDGER_PATH = os.environ.get( + "QUOTA_AWARE_AGENT_LEDGER", + os.path.expanduser("~/.quota-aware-agent/operations.jsonl"), +) + + +def _ledger() -> OperationLedger: + return OperationLedger(LEDGER_PATH) + + +def _client() -> SuperDocsClient: + key = os.environ.get("SUPERDOCS_API_KEY") + if not key: + raise RuntimeError( + "SUPERDOCS_API_KEY is not set. This server needs a SuperDocs API key; " + "an agent account can be created with POST /v1/agents/signup." + ) + return SuperDocsClient(HttpTransport(key)) + + +def _steps(raw: list[dict]) -> list[Step]: + out = [] + for i, s in enumerate(raw, 1): + out.append(Step( + step_id=str(s.get("id") or f"step-{i}"), + instruction=str(s["instruction"]), + sections=int(s.get("sections", 1)), + severity=str(s.get("severity", "medium")), + )) + return out + + +def _when_it_does_not_fit(value: str) -> WhenItDoesNotFit: + try: + return WhenItDoesNotFit(str(value).lower()) + except ValueError: + raise ValueError( + f"{value!r} is not a way to handle work that does not fit. Use " + "'degrade' to do the highest-severity part that fits, or 'refuse' " + "to do none of it." + ) from None + + +# -- the operations, as plain functions so they are testable without MCP -- + +def check_allowance() -> dict: + agent = QuotaAwareAgent(_client(), ledger=_ledger()) + balance = agent.read_allowance() + unresolved = agent.unresolved_operations() + return { + "remaining_operations": balance.ops, + "authoritative": balance.authoritative, + "resets_at": balance.as_of, + "note": ("Authoritative right now. It stops being authoritative as soon as " + "work begins: the usage endpoints reject API keys, so the balance " + "is only readable as a side effect of making calls."), + # Surfaced here rather than only inside run_work, because an agent that + # is about to plan needs to know an earlier run left something in doubt + # before it decides what to do, not after. + "started_and_never_confirmed": unresolved, + "budget": agent.budget_hint(), + } + + +def plan_work(steps: list[dict], reserve: int = 1, session_id: str = "", + when_it_does_not_fit: str = "degrade") -> dict: + """Free. Spends nothing, changes nothing, and answers 'what fits?'. + + Given a `session_id` it also consults the operation ledger, so work an + earlier run already paid for is named rather than priced. Without one it + can only price, and it says so — a plan that quietly assumes a clean slate + is the plan that disagrees with the run. + """ + policy = Policy(reserve=reserve, + when_it_does_not_fit=_when_it_does_not_fit(when_it_does_not_fit)) + agent = QuotaAwareAgent(_client(), policy=policy, ledger=_ledger()) + balance = agent.read_allowance() + parsed = _steps(steps) + if session_id: + to_price, already_applied, unconfirmed = agent.settled(session_id, parsed) + else: + to_price, already_applied, unconfirmed = parsed, [], [] + plan = agent.plan(to_price) + by_id = {s.step_id: s for s in parsed} + return { + "remaining_operations": balance.ops, + "full_request_costs": estimate([s.as_change() for s in to_price], + batched=QuotaAwareAgent.BATCHED), + "reserved": reserve, + "will_run": [c.row_id for c in plan.publish], + "will_defer": [c.row_id for c in plan.defer], + "already_applied_by_an_earlier_run": already_applied, + "started_and_never_confirmed": unconfirmed, + "fits_completely": plan.complete, + "explanation": plan.rationale or "The whole request fits inside the allowance.", + "priced_against_the_ledger": bool(session_id), + "pricing_note": ( + "Each step is its own billable request, so each one costs at least " + "one operation — the sections only add to that." + + ("" if session_id else + " No session_id was given, so this price assumes none of these " + "steps has been run before. Pass the session_id you will run " + "against to have work an earlier run already paid for excluded.") + ), + "instructions": {k: v.instruction for k, v in by_id.items()}, + "policy": agent.policy.describe(), + "budget": agent.budget_hint(), + } + + +def run_work(session_id: str, filename: str, document_html: str = "", + steps: list[dict] | None = None, reserve: int = 1, + max_steps: int | None = None, export_format: str = "docx", + document_base64: str = "", when_it_does_not_fit: str = "degrade") -> dict: + agent = QuotaAwareAgent( + _client(), + policy=Policy(reserve=reserve, max_steps=max_steps, + when_it_does_not_fit=_when_it_does_not_fit(when_it_does_not_fit)), + ledger=_ledger()) + report = agent.run(session_id, filename, + _document_bytes(document_html, document_base64), + _steps(steps or []), export_format=export_format) + return { + "completed": report.completed, + "deferred": report.deferred, + "stopped_because": report.stopped_because or None, + "plain_language": report.plain_language(), + "allowance_at_start": { + "operations": report.balance_at_start.ops, + "authoritative": report.balance_at_start.authoritative, + } if report.balance_at_start else None, + "allowance_at_end": { + "operations": report.balance_at_end.ops, + "authoritative": report.balance_at_end.authoritative, + } if report.balance_at_end else None, + "export_warnings": report.export_warnings, + "already_applied_by_an_earlier_run": report.already_applied, + "started_and_never_confirmed": report.needs_a_person, + "stop_reason": report.stop_reason.value, + "why_it_stopped": report.stop_reason.explain(), + # The line items and whether they add up. An agent that has to derive + # its own spend from a transcript will get it wrong the same way a + # person does. + "receipt": report.receipt.as_dict(report.balance_at_start, + report.balance_at_end), + "budget": agent.budget_hint(), + } + + +def resolve_operation(session_id: str, step_id: str, instruction: str, + sections: int, applied: bool, note: str = "") -> dict: + """Close out a step an earlier run started and never confirmed. + + Without this the surface has a state it can enter and never leave: a call + recorded as started-and-unconfirmed is never retried, which is correct, and + was never resolvable through the surface, which is not — the step could + never run again. Somebody has to open the document, see whether the edit is + there, and say so. This is where they say it. + + It is deliberately not automatic. Choosing between paying twice and leaving + work undone is somebody's money and somebody's document. + """ + ledger = _ledger() + key = operation_key(session_id, step_id, instruction, int(sections)) + before = ledger.get(key) + if before.state is not State.IN_FLIGHT: + return { + "resolved": False, + "state": before.state.value, + "explanation": ( + f"'{step_id}' is recorded as '{before.state.value}', not as " + "started-and-unconfirmed, so there is nothing to resolve. Only " + "a call that was sent and whose outcome was never learned needs " + "a person. Check that the session_id, step id, instruction and " + "section count match the run exactly — the ledger is keyed on " + "what the call is, not on an id you assigned." + ), + } + after = ledger.resolve(key, applied=applied, note=note) + return { + "resolved": True, + "state": after.state.value, + "explanation": ( + f"'{step_id}' is now recorded as already applied; a later run will " + "not repeat it and will not be billed for it again." + if applied else + f"'{step_id}' is now recorded as never applied; a later run will " + "send it. Only say this if you looked at the document and the edit " + "is not there." + ), + } + + +def _document_bytes(document_html: str, document_base64: str) -> bytes: + """The document to upload, from whichever form the caller had it in. + + HTML is the convenient case and base64 is the necessary one: an agent + holding a real .docx has no lossless way to put it through a JSON string + field, and SuperDocs takes documents, not raw Word XML. Offering only the + HTML field quietly restricted this build to callers who had already + converted their file — which is most of the work. + """ + import base64 as _b64 + import binascii + + if document_base64: + if document_html: + raise ValueError( + "give document_html or document_base64, not both — one of them " + "would be silently ignored and you would not know which.") + try: + return _b64.b64decode(document_base64, validate=True) + except (binascii.Error, ValueError) as e: + raise ValueError( + f"document_base64 is not valid base64 ({e}). Send the file's " + "bytes base64-encoded, not its text.") from None + if not document_html: + raise ValueError( + "no document was given. Pass document_html for HTML, or " + "document_base64 for the bytes of a .docx or .pdf.") + return document_html.encode("utf-8") + + +TOOLS = [ + { + "name": "check_allowance", + "description": ( + "Read how many SuperDocs operations remain before planning any work. " + "Free. This is the one moment the number is authoritative -- afterwards " + "it can only be inferred from the responses to work you have already done." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "plan_work", + "description": ( + "Price a list of edits against the remaining allowance and report what " + "fits, what would be deferred, and why -- WITHOUT doing any of it. Free. " + "Call this before run_work so you never start a job you cannot finish." + ), + "inputSchema": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "description": "The edits you want to make.", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "instruction": {"type": "string", + "description": "A natural-language edit instruction."}, + "sections": {"type": "integer", "minimum": 1, + "description": "Roughly how many document sections it " + "touches. SuperDocs bills one operation " + "per 25 sections edited."}, + "severity": {"type": "string", + "enum": ["critical", "high", "medium", "low"], + "description": "Highest severity runs first when not " + "everything fits."}, + }, + "required": ["instruction"], + }, + }, + "reserve": {"type": "integer", "minimum": 0, "default": 1, + "description": "Operations held back so finished work can still " + "be exported."}, + "session_id": {"type": "string", + "description": "The session you will run against. Given one, " + "steps an earlier run already paid for are " + "excluded from the price instead of quoted."}, + "when_it_does_not_fit": { + "type": "string", "enum": ["degrade", "refuse"], "default": "degrade", + "description": "degrade: do the highest-severity part that fits. " + "refuse: do none of it rather than deliver a subset."}, + }, + "required": ["steps"], + }, + }, + { + "name": "run_work", + "description": ( + "Upload a document, apply the edits that fit inside the remaining " + "allowance, approve the proposed changes, and export the result. " + "Degrades rather than failing halfway: it never starts a step it cannot " + "finish, and it reports in plain language what it left out." + ), + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "filename": {"type": "string"}, + "document_html": {"type": "string", + "description": "The document as HTML. SuperDocs takes " + "documents and HTML, never raw Word XML. " + "Use document_base64 instead for a real file."}, + "document_base64": {"type": "string", + "description": "The document's bytes, base64-encoded — for " + "a .docx or .pdf you already hold. Give this " + "or document_html, never both."}, + "steps": {"type": "array", "items": {"type": "object"}}, + "reserve": {"type": "integer", "default": 1}, + "max_steps": {"type": ["integer", "null"], + "description": "Small-sample bound: run at most this many steps."}, + "export_format": {"type": "string", + "enum": ["docx", "pdf", "html", "markdown", "txt"], + "default": "docx"}, + "when_it_does_not_fit": { + "type": "string", "enum": ["degrade", "refuse"], "default": "degrade", + "description": "degrade: do the highest-severity part that fits and name " + "the rest. refuse: start nothing rather than deliver a " + "subset."}, + }, + "required": ["session_id", "filename", "steps"], + }, + }, + { + "name": "resolve_operation", + "description": ( + "Close out a step that check_allowance or run_work reported as " + "'started and never confirmed'. Such a step is never retried automatically, " + "because retrying might be charged twice and might apply the same edit twice — " + "so a person opens the document, sees whether the edit is there, and records " + "the answer here. Free. Until it is resolved, that step will not run again." + ), + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "step_id": {"type": "string"}, + "instruction": {"type": "string", + "description": "The instruction exactly as it was sent. The " + "ledger is keyed on what the call is, not on " + "an id you assigned."}, + "sections": {"type": "integer", "minimum": 1, + "description": "The section count exactly as it was sent."}, + "applied": {"type": "boolean", + "description": "true if the edit IS in the document — it will not " + "be sent or billed again. false if it is NOT — it " + "will be sent on the next run."}, + "note": {"type": "string", "description": "What you saw, for the record."}, + }, + "required": ["session_id", "step_id", "instruction", "sections", "applied"], + }, + }, +] + +_HANDLERS = { + "check_allowance": lambda a: check_allowance(), + "plan_work": lambda a: plan_work( + a["steps"], a.get("reserve", 1), a.get("session_id", ""), + a.get("when_it_does_not_fit", "degrade"), + ), + "run_work": lambda a: run_work( + a["session_id"], a["filename"], a.get("document_html", ""), a["steps"], + a.get("reserve", 1), a.get("max_steps"), a.get("export_format", "docx"), + a.get("document_base64", ""), a.get("when_it_does_not_fit", "degrade"), + ), + "resolve_operation": lambda a: resolve_operation( + a["session_id"], a["step_id"], a["instruction"], a["sections"], + bool(a["applied"]), a.get("note", ""), + ), +} + + +def dispatch(name: str, arguments: dict) -> dict: + """The whole surface, callable without an MCP client -- which is what makes + it testable offline.""" + if name not in _HANDLERS: + raise KeyError(f"no tool named {name!r}") + return _HANDLERS[name](arguments or {}) + + +async def serve() -> None: + """Serve the three tools over stdio. + + Written against the MCP SDK's constructor-callback API (2.x). The older + `@server.list_tools()` decorators do not exist there, and a server that + imports cleanly but cannot start is worse than one that fails loudly, so + `test_the_server_starts_and_advertises_its_tools` drives a real client. + """ + import mcp.types as types + from mcp.server import Server + from mcp.server.stdio import stdio_server + + def _tool(spec: dict) -> "types.Tool": + return types.Tool( + name=spec["name"], + description=spec["description"], + input_schema=spec["inputSchema"], + ) + + async def on_list_tools(ctx, params) -> "types.ListToolsResult": + return types.ListToolsResult(tools=[_tool(t) for t in TOOLS]) + + async def on_call_tool(ctx, params) -> "types.CallToolResult": + try: + result = dispatch(params.name, params.arguments or {}) + failed = False + except Exception as e: + # An agent must be able to READ the failure. Letting it propagate + # drops the connection and tells the caller nothing actionable. + result, failed = {"error": str(e)}, True + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(result, indent=2))], + is_error=failed, + ) + + server = Server( + SERVER_NAME, + version="1.0.0", + instructions=( + "Call check_allowance first, then plan_work to see what fits, then " + "run_work. Everything except run_work is free and changes nothing. " + "If check_allowance reports work started and never confirmed, a " + "person has to look at the document and answer with " + "resolve_operation before that step can run again." + ), + on_list_tools=on_list_tools, + on_call_tool=on_call_tool, + ) + + async with stdio_server() as (read, write): + await server.run(read, write, server.create_initialization_options()) + + +def main() -> None: + import asyncio + + asyncio.run(serve()) + + +if __name__ == "__main__": + main() diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/policy.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/policy.py new file mode 100644 index 00000000..9b58b5bb --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/policy.py @@ -0,0 +1,138 @@ +"""The rules this agent runs under, as data rather than as three arguments. + +`budget.py` prices work and fits it to a number. What it must never do is +decide *how much* to hold back, *how many* steps a loop may take, or what to do +when the work does not fit — those are choices an operator makes, and they +belong somewhere an operator can read them. + +They were arguments to the constructor, which is fine until somebody has to +answer *why did it stop?* — and the honest answer is a rule with a name, not a +number that was too low. That distinction is the whole of this module. From the +research: an engineer described putting in a hard cap, having it fire twice on +runs that were fine, and raising the number until it stopped firing. A limit +whose only feedback is "I stopped" trains the person to disable it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from enum import Enum + + +class WhenItDoesNotFit(str, Enum): + """What to do with work that will not fit in the remaining allowance.""" + + #: Do the highest-severity part that fits and name what was left out. + DEGRADE = "degrade" + #: Do none of it. For a caller who would rather have nothing than a subset. + REFUSE = "refuse" + + +class StopReason(str, Enum): + """Why a run ended. Every one of these is a rule, and every rule can say + what it was protecting — which is what makes it arguable rather than just + obstructive.""" + + COMPLETED = "completed" + QUOTA_EXHAUSTED = "quota_exhausted" + RESERVE_FLOOR = "reserve_floor" + NOTHING_FITS = "nothing_fits" + REFUSED_PARTIAL = "refused_partial" + SAMPLE_BOUND = "sample_bound" + ALREADY_APPLIED = "already_applied" + STARTED_AND_UNKNOWN = "started_and_unknown" + + def explain(self) -> str: + return _WHY[self] + + +_WHY: dict[StopReason, str] = { + StopReason.COMPLETED: "All of the requested work was done.", + StopReason.QUOTA_EXHAUSTED: ( + "SuperDocs itself reported the allowance exhausted. That is the " + "platform's own signal and the only authoritative one — our arithmetic " + "never overrules it, and raising a number here would not help." + ), + StopReason.RESERVE_FLOOR: ( + "The reserve was reached. It exists so the work already done can still " + "be exported: exports are free, so holding one operation back costs " + "nothing and guarantees you end up with a file rather than a session." + ), + StopReason.NOTHING_FITS: ( + "None of the requested work fits inside the remaining allowance. " + "Nothing was uploaded and nothing was billed, so there is no " + "half-edited document to clean up." + ), + StopReason.REFUSED_PARTIAL: ( + "Only part of the work fits, and this policy is set to refuse a partial " + "run rather than deliver a subset. Set when_it_does_not_fit=DEGRADE to " + "take the part that fits." + ), + StopReason.SAMPLE_BOUND: ( + "The small-sample bound was reached. Anything that loops needs a " + "stopping rule; this one is yours and it did what you asked." + ), + StopReason.ALREADY_APPLIED: ( + "This step was applied by an earlier run and was not repeated. " + "SuperDocs has no idempotency on billable writes, so repeating it would " + "have been charged again." + ), + StopReason.STARTED_AND_UNKNOWN: ( + "An earlier run started this step and never learned whether it " + "finished. It was not repeated, because repeating it might be charged " + "twice and might apply the same edit twice. It needs a person to look " + "at the document." + ), +} + + +@dataclass(frozen=True) +class Policy: + """How much to hold back, how far to go, and what to do when it will not fit. + + Frozen, so a policy that was read at the start of a run is the policy the + run ended under. `with_` returns a modified copy rather than mutating, for + the same reason. + """ + + #: Operations never spent on new edits, so finished work can always be + #: exported. Exports are free, so this costs the user nothing. + reserve: int = 1 + #: The small-sample bound. None means "as many as were asked for". + max_steps: int | None = None + when_it_does_not_fit: WhenItDoesNotFit = WhenItDoesNotFit.DEGRADE + + def __post_init__(self) -> None: + if self.reserve < 0: + raise ValueError("a reserve cannot be negative") + if self.max_steps is not None and self.max_steps < 1: + raise ValueError("a sample bound of zero would do nothing and say nothing") + + def spendable(self, remaining_ops: int) -> int: + """What may be spent on new edits, which is not what is left.""" + return max(0, remaining_ops - self.reserve) + + def bound(self, steps: list) -> tuple[list, bool]: + """Apply the sample bound. Returns the steps and whether it bit.""" + if self.max_steps is None or len(steps) <= self.max_steps: + return list(steps), False + return list(steps[: self.max_steps]), True + + def with_(self, **changes) -> "Policy": + return replace(self, **changes) + + def describe(self) -> str: + parts = [ + f"Holding back {self.reserve} operation(s) so finished work can " + "always be exported." + ] + if self.max_steps is not None: + parts.append(f"At most {self.max_steps} step(s) in one run.") + parts.append( + "Work that does not fit is sized down by severity and what is left " + "out is named." + if self.when_it_does_not_fit is WhenItDoesNotFit.DEGRADE + else "Work that does not fit is refused whole rather than delivered " + "in part." + ) + return " ".join(parts) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/receipt.py b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/receipt.py new file mode 100644 index 00000000..eb197efb --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/backend/quota_aware_agent/receipt.py @@ -0,0 +1,200 @@ +"""What a run actually spent, line by line, and whether that adds up. + +From the research, an engineer describing what they did after a job died +halfway: *"I added it up from the responses I'd logged, and it didn't match the +number in the account, so I stopped looking."* + +Two failures in one sentence. They had to do the arithmetic by hand, and when +the arithmetic disagreed with the platform there was nothing to tell them which +side was wrong — so the disagreement went uninvestigated. A receipt does the +adding up, and `reconcile` does the comparison and **names the disagreement +instead of hiding it**. + +The one rule this module refuses to break: a number the platform did not state +is never presented as one it did. An operation charged is what a `usage` block +said; an operation *estimated* is what we believed. They are separate columns, +they are never summed together, and a receipt whose balances were not +authoritative says the reconciliation could not be done rather than doing it on +guesses. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field + +from .budget import Balance + + +@dataclass(frozen=True) +class Line: + """One call, and what it cost.""" + + step_id: str + call: str + billable: bool + #: What the platform's `usage` block said it charged. None when no usage + #: block came back, which is not the same as zero. + ops_charged: int | None = None + #: What we believed it would cost, before it happened. + ops_estimated: int = 0 + balance_after: int | None = None + balance_authoritative: bool = False + note: str = "" + + @property + def counted(self) -> int: + """Only what the platform stated. Never the estimate.""" + return self.ops_charged or 0 + + +@dataclass(frozen=True) +class Reconciliation: + checkable: bool + agrees: bool + counted: int + implied: int | None + explanation: str + + +@dataclass +class Receipt: + """The line items of one run, in the order they happened.""" + + session_id: str = "" + lines: list[Line] = field(default_factory=list) + + def record(self, step_id: str, call: str, *, billable: bool, + ops_charged: int | None = None, ops_estimated: int = 0, + balance: Balance | None = None, note: str = "") -> Line: + line = Line( + step_id=step_id, call=call, billable=billable, + ops_charged=ops_charged, ops_estimated=ops_estimated, + balance_after=balance.ops if balance else None, + balance_authoritative=bool(balance and balance.authoritative), + note=note, + ) + self.lines.append(line) + return line + + # -- what it adds up to ------------------------------------------------ + @property + def counted(self) -> int: + """Operations the platform said it charged.""" + return sum(l.counted for l in self.lines) + + @property + def estimated_only(self) -> int: + """Operations we believe were charged on calls that reported nothing. + + Kept apart from `counted` on purpose. Adding them would produce one + number that is part measurement and part belief, and nobody downstream + could tell which part. + """ + return sum(l.ops_estimated for l in self.lines + if l.billable and l.ops_charged is None) + + @property + def billable_calls(self) -> int: + return sum(1 for l in self.lines if l.billable) + + @property + def unreported_billable_calls(self) -> int: + return sum(1 for l in self.lines if l.billable and l.ops_charged is None) + + def reconcile(self, start: Balance | None, end: Balance | None) -> Reconciliation: + """Does what the lines add up to match what the balance moved by? + + Only checkable when both ends of the run were *authoritative*. An + estimate on either end makes the difference an estimate too, and a + reconciliation between two guesses is theatre. + """ + counted = self.counted + if start is None or end is None: + return Reconciliation( + False, False, counted, None, + "There is no starting or ending balance to compare against.") + if not (start.authoritative and end.authoritative): + which = [] + if not start.authoritative: + which.append("the starting balance") + if not end.authoritative: + which.append("the ending balance") + return Reconciliation( + False, False, counted, None, + f"{' and '.join(which).capitalize()} was inferred rather than " + "stated by the platform, so this run cannot be reconciled. " + f"{counted} operation(s) were confirmed charged" + + (f", and {self.estimated_only} more were estimated on calls " + "that reported no usage." if self.estimated_only else "."), + ) + + implied = start.ops - end.ops + if implied == counted: + return Reconciliation( + True, True, counted, implied, + f"{counted} operation(s) charged, and the allowance moved by " + f"{implied}. These agree.") + gap = implied - counted + return Reconciliation( + True, False, counted, implied, + f"The line items add up to {counted} operation(s), but the allowance " + f"moved by {implied}. That is a difference of {gap}. It is reported " + "rather than reconciled away: something was charged that this run " + "did not record, or something else is spending against the same " + "account.", + ) + + # -- handing it over --------------------------------------------------- + def as_dict(self, start: Balance | None = None, + end: Balance | None = None) -> dict: + rec = self.reconcile(start, end) + return { + "session_id": self.session_id, + "lines": [asdict(l) for l in self.lines], + "billable_calls": self.billable_calls, + "operations_charged": self.counted, + "operations_estimated_only": self.estimated_only, + "calls_that_reported_no_usage": self.unreported_billable_calls, + "reconciliation": asdict(rec), + } + + def as_json(self, start: Balance | None = None, + end: Balance | None = None) -> str: + return json.dumps(self.as_dict(start, end), indent=2) + + def render_text(self, start: Balance | None = None, + end: Balance | None = None) -> str: + out = ["RUN RECEIPT" + (f" · {self.session_id}" if self.session_id else ""), + "=" * 62] + if start: + out.append(f"Allowance at the start: {start}") + out.append("") + out.append(f"{'STEP':<18}{'CALL':<12}{'CHARGED':>9}{'LEFT':>9}") + out.append("-" * 62) + for l in self.lines: + charged = "—" if l.ops_charged is None else str(l.ops_charged) + if l.ops_charged is None and l.billable: + charged = f"~{l.ops_estimated}" + left = "—" if l.balance_after is None else ( + str(l.balance_after) + ("" if l.balance_authoritative else "?")) + out.append(f"{l.step_id[:17]:<18}{l.call[:11]:<12}{charged:>9}{left:>9}") + if l.note: + out.append(f" {l.note}") + out.append("-" * 62) + out.append(f"{'confirmed charged':<30}{self.counted:>9}") + if self.estimated_only: + out.append(f"{'estimated, never confirmed':<30}{self.estimated_only:>9}") + if end: + out.append(f"Allowance at the end: {end}") + out.append("") + rec = self.reconcile(start, end) + out.append("DOES IT ADD UP?") + out.append("-" * 62) + out.append(rec.explanation) + out.append("") + out.append("A '~' is an operation we believe was charged on a call that " + "returned no usage") + out.append("block. A '?' is a balance we inferred rather than one the " + "platform stated.") + return "\n".join(out) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/pyproject.toml b/use-cases/Priyanshu2425/quota-aware-agent/pyproject.toml new file mode 100644 index 00000000..f9187838 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "quota-aware-agent" +version = "1.0.0" +description = "An agent that checks its SuperDocs allowance before planning, sizes the work to fit, and degrades in plain language." +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +# The MCP surface is optional: the agent, the library and the CLI demo all work +# without it, and the tests skip the protocol check when it is absent. +mcp = ["mcp>=1.0"] +dev = ["pytest"] + +[project.scripts] +quota-aware-agent-mcp = "quota_aware_agent.mcp_server:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["backend"] +include = ["quota_aware_agent*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["backend"] +addopts = "-q" diff --git a/use-cases/Priyanshu2425/quota-aware-agent/screenshot.png b/use-cases/Priyanshu2425/quota-aware-agent/screenshot.png new file mode 100644 index 00000000..01e86f5a Binary files /dev/null and b/use-cases/Priyanshu2425/quota-aware-agent/screenshot.png differ diff --git a/use-cases/Priyanshu2425/quota-aware-agent/tests/fake.py b/use-cases/Priyanshu2425/quota-aware-agent/tests/fake.py new file mode 100644 index 00000000..d6ff2daa --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/tests/fake.py @@ -0,0 +1,131 @@ +"""A fake SuperDocs. Every response shape here is taken from the documented +payloads, including the double-parsed proposed-change envelope.""" + +from __future__ import annotations + +import json + +from quota_aware_agent.client import Response + + +class FakeSuperDocs: + def __init__(self, remaining: int = 500, monthly_limit: int = 500, + sections_per_edit: int = 1, slow_polls: int = 0, + exhaust_after: int | None = None, settle_polls: int = 1, + envelope_pending: bool = False) -> None: + self.remaining = remaining + self.monthly_limit = monthly_limit + self.sections_per_edit = sections_per_edit + self.slow_polls = slow_polls + self.exhaust_after = exhaust_after + self.settle_polls = settle_polls + self.envelope_pending = envelope_pending + self._approved_at_poll: int | None = None + self.calls: list[tuple[str, str]] = [] + self._polls = 0 + self._edits = 0 + self.approved: list[dict] = [] + self.uploaded: list[str] = [] + + # -- helpers ---------------------------------------------------------- + def _usage(self, ops: int) -> dict: + self.remaining = max(0, self.remaining - ops) + exhausted = ( + self.exhaust_after is not None and self._edits >= self.exhaust_after + ) or self.remaining == 0 + return { + "monthly_used": self.monthly_limit - self.remaining, + "monthly_limit": self.monthly_limit, + "monthly_remaining": self.remaining, + "was_billable": ops > 0, + "ops_charged": ops, + "quota_exhausted": exhausted, + "subscription_tier": "free", + } + + # -- transport -------------------------------------------------------- + def request(self, method: str, path: str, **kw) -> Response: + self.calls.append((method, path)) + + if path == "/v1/agents/whoami": + return Response(200, { + "account_id": "fake", + "quota": {"tier": "free", "monthly_limit": self.monthly_limit, + "used": self.monthly_limit - self.remaining, + "remaining": self.remaining, + "resets_at": "2026-09-01T00:00:00+00:00"}, + }) + + if path == "/v1/documents/upload": + # The real endpoint answers 422 when the multipart `file` part is + # absent. Modelled here because a fake that accepts anything is how + # a transport that encoded nothing passed its tests. + files = kw.get("files") or {} + if "file" not in files: + return Response(422, {"detail": [ + {"type": "missing", "loc": ["body", "file"], "msg": "Field required"} + ]}) + name, body = files["file"] + # The live API parses by EXTENSION, not by sniffing the bytes: HTML + # sent as report.docx is answered 400. Modelled here because a fake + # that accepts any filename is how the demo shipped uploading HTML + # under a .docx name and nobody found out until a live run. + # Verified against the live API 2026-08-20. + if (str(name).lower().endswith((".docx", ".xlsx", ".pptx", ".odt")) + and not bytes(body)[:4] == b"PK\x03\x04"): + return Response(400, {"detail": "Invalid DOCX file: File is not a zip file"}) + self.uploaded.append(name) + return Response(200, {"status": "ok", "chunks_count": 2}) + + if path == "/v1/chat/async": + self._edits += 1 + self._polls = 0 + self._approved_at_poll = None + ops = max(1, -(-self.sections_per_edit // 25)) + return Response(200, {"job_id": f"job-{self._edits}", "status": "pending", + "usage": self._usage(ops)}) + + if path.startswith("/v1/jobs/"): + self._polls += 1 + if self._approved_at_poll is not None: + # After approval the job resumes; it needs at least one more + # poll before it is safe to export. + if self._polls <= self._approved_at_poll + self.settle_polls: + return Response(200, {"status": "in_progress"}) + return Response(200, {"status": "completed"}) + if self._polls <= self.slow_polls: + # Trap 2: a long quiet run. No usage block, still processing. + return Response(200, {"status": "in_progress"}) + if self.envelope_pending: + # The SSE `proposed_change_batch` shape: content is a JSON + # *string* needing a second parse (Trap 1). + pending = {"content": json.dumps({"changes": [ + {"change_id": "ch_1", "chunk_id": "c1", "diff": "

after

"}, + ]})} + else: + # What GET /v1/jobs/{id} actually returns: a plain list. + # Verified against the live API 2026-08-19. + pending = [{"change_id": "ch_1", "chunk_id": "c1", + "old_html": "

before

", "new_html": "

after

"}] + return Response(200, { + "status": "awaiting_approval", + "metadata": {"pending_changes": pending}, + }) + + if path.endswith("/approve"): + self.approved.extend(kw["json"]["changes"]) + # The real endpoint returns {"status": "ok"} and the job keeps + # running; it reaches "completed" only on a later poll. Returning + # "completed" here hid a race that silently exported the pre-edit + # document. Verified live 2026-08-19. + self._approved_at_poll = self._polls + return Response(200, {"status": "ok", "usage": self._usage(0)}) + + if path == "/v1/documents/export": + import base64 + warn = base64.b64encode(json.dumps( + [{"code": "field_code_unsupported", "detail": "a citation was skipped"}] + ).encode()).decode() + return Response(200, {"raw": b"DOCX"}, {"X-Export-Warnings": warn}) + + return Response(404, {"error": "no such path"}) diff --git a/use-cases/Priyanshu2425/quota-aware-agent/tests/test_agent.py b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_agent.py new file mode 100644 index 00000000..6dd30305 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_agent.py @@ -0,0 +1,227 @@ +"""Keyless. No network. These prove the card's claims, not the fake's.""" + +import pytest + +from quota_aware_agent import QuotaAwareAgent, Step, SuperDocsClient, parse_proposed_changes +from quota_aware_agent.client import QuotaExhausted +from quota_aware_agent.policy import Policy +from tests.fake import FakeSuperDocs + +DOC = b"a document" + + +def agent(fake, **kw): + return QuotaAwareAgent(SuperDocsClient(fake, sleep=lambda s: None), + policy=Policy(**kw)) + + +def steps(n, sections=1, severity="medium"): + return [Step(f"s{i}", f"edit {i}", sections, severity) for i in range(1, n + 1)] + + +def test_reads_allowance_before_it_plans_or_uploads(): + """The card's actual requirement: allowance first, then planning.""" + fake = FakeSuperDocs(remaining=500) + agent(fake).run("sess", "d.html", DOC, steps(1)) + paths = [p for _, p in fake.calls] + assert paths[0] == "/v1/agents/whoami" + assert paths.index("/v1/agents/whoami") < paths.index("/v1/documents/upload") + + +def test_never_starts_work_it_cannot_finish(): + """One operation left, all of it reserved. Nothing is uploaded at all -- + the point is that no half-applied state exists, not that it fails politely.""" + fake = FakeSuperDocs(remaining=1) + report = agent(fake, reserve=1).run("sess", "d.html", DOC, steps(3)) + assert report.completed == [] + assert "/v1/documents/upload" not in [p for _, p in fake.calls] + assert "did not start" in report.plain_language().lower() + assert report.stopped_because + + +def test_degrades_by_severity_and_says_what_it_left_out(): + """Two operations of room, four operations of work. Critical work survives, + low-severity work is named as deferred in plain language.""" + fake = FakeSuperDocs(remaining=3) + work = [ + Step("critical", "fix the numbers", 25, "critical"), + Step("low", "tidy the footer", 25, "low"), + Step("high", "fix the dates", 25, "high"), + ] + report = agent(fake, reserve=1).run("sess", "d.html", DOC, work) + assert "critical" in report.planned + assert "low" in report.deferred + text = report.plain_language() + assert "Left undone" in text and "low" in text + assert "operations" in text + + +def test_the_four_calls_happen_in_order(): + fake = FakeSuperDocs(remaining=500) + agent(fake).run("sess", "d.html", DOC, steps(1)) + paths = [p for _, p in fake.calls] + assert paths.index("/v1/documents/upload") < paths.index("/v1/chat/async") + assert paths.index("/v1/chat/async") < [i for i, p in enumerate(paths) if p.endswith("/approve")][0] + assert paths.index("/v1/documents/export") == len(paths) - 1 + + +def test_trap_1_the_double_parse(): + """A batch whose content is a JSON string must survive the second parse.""" + import json + envelope = {"content": json.dumps({"changes": [{"change_id": "ch_9"}]})} + assert parse_proposed_changes(envelope) == [{"change_id": "ch_9"}] + + +def test_trap_1_end_to_end_the_change_is_actually_approved(): + """The regression the docs warn about is silent: undefined fields, nothing + approved, no error. So assert the change id arrived, not just that it ran.""" + fake = FakeSuperDocs(remaining=500) + agent(fake).run("sess", "d.html", DOC, steps(1)) + assert fake.approved == [{"change_id": "ch_1", "approved": True}] + + +def test_trap_2_a_long_silence_is_not_a_crash(): + fake = FakeSuperDocs(remaining=500, slow_polls=5) + report = agent(fake).run("sess", "d.html", DOC, steps(1)) + assert report.completed == ["s1"] + + +def test_trap_2_a_deadline_says_it_is_our_deadline_not_a_platform_failure(): + fake = FakeSuperDocs(remaining=500, slow_polls=10_000) + client = SuperDocsClient(fake, sleep=lambda s: None) + client.edit("sess", "go") + with pytest.raises(TimeoutError) as e: + client.poll_job("job-1", deadline_s=4, interval_s=2) + assert "not a platform failure" in str(e.value) + + +def test_trap_3_small_sample_mode_bounds_the_loop(): + fake = FakeSuperDocs(remaining=500) + report = agent(fake, max_steps=2).run("sess", "d.html", DOC, steps(10)) + assert len(report.completed) == 2 + assert "Small-sample mode" in report.plain_language() + + +def test_the_platform_signal_stops_it_even_with_budget_left(): + """quota_exhausted is authoritative. Our own arithmetic is not.""" + fake = FakeSuperDocs(remaining=500, exhaust_after=1) + report = agent(fake).run("sess", "d.html", DOC, steps(3)) + assert "exhausted" in report.stopped_because + assert len(report.completed) < 3 + + +def test_it_still_exports_after_stopping_early(): + """Exports are free, so stopping early must never cost the user the work + already done.""" + fake = FakeSuperDocs(remaining=500, exhaust_after=1) + agent(fake).run("sess", "d.html", DOC, steps(3)) + assert "/v1/documents/export" in [p for _, p in fake.calls] + + +def test_export_warnings_are_surfaced_not_swallowed(): + fake = FakeSuperDocs(remaining=500) + report = agent(fake).run("sess", "d.html", DOC, steps(1)) + assert report.export_warnings + assert "non-fatal" in report.plain_language() + + +def test_a_balance_between_calls_is_never_presented_as_a_live_read(): + """BUG-003: the usage endpoints reject API keys, so the number is only + authoritative at whoami and after each response.""" + from quota_aware_agent import BudgetGuard + g = BudgetGuard() + g.seed_from_whoami(500) + assert g.remaining().authoritative + g.reconcile(ops_charged=1, monthly_remaining=None, quota_exhausted=False) + assert not g.remaining().authoritative + assert "estimated" in str(g.remaining()) + + +def test_an_unknown_allowance_is_planned_as_zero_never_guessed(): + class NoQuota(FakeSuperDocs): + def request(self, method, path, **kw): + if path == "/v1/agents/whoami": + return __import__("quota_aware_agent.client", fromlist=["Response"]).Response(200, {}) + return super().request(method, path, **kw) + + fake = NoQuota(remaining=500) + report = agent(fake).run("sess", "d.html", DOC, steps(2)) + assert report.completed == [] + assert report.balance_at_start.ops == 0 + + +# -- gaps found by running the contract against the live API, 2026-08-19 ----- + +def test_the_transport_actually_encodes_multipart(): + """The four-call contract passed for weeks against a fake that accepted any + kwargs, while `HttpTransport` dropped `files=` entirely and the real + endpoint answered 422. A fake that accepts anything proves nothing.""" + from quota_aware_agent.client import _encode_multipart + + body, ctype = _encode_multipart( + {"file": ("memo.docx", b"CONTENT-BYTES")}, {"session_id": "s1"} + ) + assert ctype.startswith("multipart/form-data; boundary=") + boundary = ctype.split("boundary=")[1] + assert b'name="file"; filename="memo.docx"' in body + assert b"CONTENT-BYTES" in body + assert b'name="session_id"' in body and b"s1" in body + assert body.rstrip().endswith(f"--{boundary}--".encode()) + + +def test_an_upload_without_a_file_part_is_refused_by_the_fake_too(): + """Pins the contract the live API enforces, so the transport cannot + regress to sending nothing.""" + fake = FakeSuperDocs(remaining=500) + from quota_aware_agent.client import SuperDocsError + + with pytest.raises(SuperDocsError) as e: + SuperDocsClient(fake, sleep=lambda s: None)._check( + fake.request("POST", "/v1/documents/upload", json={"session_id": "s"}) + ) + assert e.value.status == 422 + + +def test_it_waits_for_the_job_to_settle_before_exporting(): + """Approval is asynchronous. The approve call returns ok and the job keeps + running; exporting before it completes returns the document unchanged with + a 200 and a valid file, so the loss is silent. Verified live.""" + fake = FakeSuperDocs(remaining=500, settle_polls=2) + agent(fake).run("sess", "d.html", DOC, steps(1)) + + paths = [p for _, p in fake.calls] + approve_at = next(i for i, p in enumerate(paths) if p.endswith("/approve")) + export_at = paths.index("/v1/documents/export") + polls_between = [p for p in paths[approve_at:export_at] if p.startswith("/v1/jobs/")] + assert polls_between, "exported without waiting for the job to settle after approving" + + +def test_pending_changes_as_a_plain_list_is_handled(): + """GET /v1/jobs/{id} returns `pending_changes` as a list, not the + double-parsed envelope the SSE stream delivers. Both shapes must work.""" + for envelope in (False, True): + fake = FakeSuperDocs(remaining=500, envelope_pending=envelope) + agent(fake).run("sess", "d.html", DOC, steps(1)) + assert fake.approved == [{"change_id": "ch_1", "approved": True}], \ + f"envelope={envelope} failed to approve the change" + + +def test_a_billable_call_with_no_usage_block_stops_claiming_confirmed(): + """The docs say usage rides on every chat response. The async endpoints + return none, so the agent kept printing the seeded whoami number as + "confirmed" while the true balance had already moved — 499 confirmed + against a real 498. A guess must announce itself as a guess.""" + class NoUsage(FakeSuperDocs): + def request(self, method, path, **kw): + r = super().request(method, path, **kw) + if isinstance(r.body, dict): + r.body.pop("usage", None) + return r + + fake = NoUsage(remaining=500) + report = agent(fake).run("sess", "d.html", DOC, steps(1)) + + assert report.balance_at_start.authoritative # whoami is real + assert not report.balance_at_end.authoritative # everything after is not + assert "estimated" in str(report.balance_at_end) + assert report.balance_at_end.ops < report.balance_at_start.ops diff --git a/use-cases/Priyanshu2425/quota-aware-agent/tests/test_governance.py b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_governance.py new file mode 100644 index 00000000..aa0fc7a8 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_governance.py @@ -0,0 +1,287 @@ +"""The policy, the receipt and the operation ledger. + +Three things an engineer described doing by hand after a run died halfway +through a batch: guessing where to restart and paying twice for the overlap, +adding the spend up from logs and abandoning it when it did not match the +account, and putting in a hard cap that only ever said "I stopped". +""" + +from __future__ import annotations + +import json + +import pytest + +from quota_aware_agent import QuotaAwareAgent, Step, SuperDocsClient +from quota_aware_agent.budget import Balance +from quota_aware_agent.idempotency import (OperationLedger, State, + operation_key) +from quota_aware_agent.policy import Policy, StopReason, WhenItDoesNotFit +from quota_aware_agent.receipt import Receipt +from tests.fake import FakeSuperDocs + +DOC = b"

x

" + + +def agent(fake, **kw): + return QuotaAwareAgent(SuperDocsClient(fake, sleep=lambda s: None), **kw) + + +def steps(n: int, sections: int = 10) -> list[Step]: + return [Step(f"s{i}", f"rewrite section {i}", sections) for i in range(n)] + + +# -- the policy ------------------------------------------------------------- + +def test_the_reserve_is_held_back_from_what_may_be_spent(): + p = Policy(reserve=2) + assert p.spendable(5) == 3 + assert p.spendable(1) == 0 # never negative + + +def test_a_sample_bound_of_zero_is_refused_rather_than_silently_doing_nothing(): + with pytest.raises(ValueError): + Policy(max_steps=0) + with pytest.raises(ValueError): + Policy(reserve=-1) + + +def test_a_policy_is_frozen_so_a_run_ends_under_the_policy_it_began_with(): + p = Policy(reserve=1) + with pytest.raises(Exception): + p.reserve = 5 # type: ignore[misc] + assert p.with_(reserve=5).reserve == 5 and p.reserve == 1 + + +def test_every_stop_reason_says_what_the_rule_was_protecting(): + """A limit whose only feedback is 'I stopped' trains the person to raise it + until it stops firing. Each of these has to be arguable.""" + for reason in StopReason: + if reason is StopReason.COMPLETED: + continue # not a rule firing; nothing to argue with + why = reason.explain() + assert len(why) > 40, f"{reason.value} does not explain itself" + assert why[0].isupper() and why.rstrip().endswith(".") + + +def test_refusing_a_partial_run_is_a_choice_a_caller_can_make(fake_tight): + """Some callers would rather have nothing than a subset — a document half + updated is worse than one not updated at all.""" + a = agent(fake_tight, policy=Policy(reserve=0, + when_it_does_not_fit=WhenItDoesNotFit.REFUSE)) + report = a.run("sess", "d.html", DOC, steps(4, sections=30)) + assert report.completed == [] + assert report.stop_reason is StopReason.REFUSED_PARTIAL + assert "refuse" in report.stopped_because.lower() + + +def test_degrading_is_still_the_default(fake_tight): + report = agent(fake_tight, policy=Policy(reserve=0)).run( + "sess", "d.html", DOC, steps(4, sections=30)) + assert report.completed and report.deferred + + +# -- the receipt ------------------------------------------------------------ + +def _receipt() -> Receipt: + r = Receipt(session_id="sess") + r.record("s0", "edit", billable=True, ops_charged=1, ops_estimated=1, + balance=Balance(4, authoritative=True)) + r.record("s0", "poll", billable=False, ops_charged=0, + balance=Balance(4, authoritative=True)) + return r + + +def test_what_the_platform_stated_and_what_we_believed_are_never_added_together(): + r = _receipt() + r.record("s1", "edit", billable=True, ops_charged=None, ops_estimated=2, + balance=Balance(2, authoritative=False)) + assert r.counted == 1 # only what a usage block said + assert r.estimated_only == 2 # kept apart, deliberately + assert r.unreported_billable_calls == 1 + + +def test_a_run_that_adds_up_says_so(): + rec = _receipt().reconcile(Balance(5, authoritative=True), + Balance(4, authoritative=True)) + assert rec.checkable and rec.agrees + assert "agree" in rec.explanation + + +def test_a_run_that_does_not_add_up_names_the_gap_instead_of_hiding_it(): + """'It didn't match the number in the account, so I stopped looking.'""" + rec = _receipt().reconcile(Balance(5, authoritative=True), + Balance(2, authoritative=True)) + assert rec.checkable and not rec.agrees + assert "difference of 2" in rec.explanation + assert "something was charged that this run did not record" in rec.explanation + + +def test_it_refuses_to_reconcile_two_numbers_it_inferred(): + """A reconciliation between two guesses is theatre.""" + rec = _receipt().reconcile(Balance(5, authoritative=True), + Balance(4, authoritative=False)) + assert not rec.checkable + assert "inferred rather than stated" in rec.explanation + + +def test_the_rendered_receipt_marks_every_number_it_did_not_get_from_the_platform(): + r = _receipt() + r.record("s1", "edit", billable=True, ops_charged=None, ops_estimated=2, + balance=Balance(2, authoritative=False)) + text = r.render_text(Balance(5, authoritative=True), + Balance(2, authoritative=False)) + assert "~2" in text # an operation we believe, not one we were told + assert "2?" in text # a balance we inferred + assert "cannot be reconciled" in text + + +def test_a_receipt_survives_being_serialised(fake): + report = agent(fake).run("sess", "d.html", DOC, steps(1)) + body = json.loads(report.receipt.as_json(report.balance_at_start, + report.balance_at_end)) + assert body["lines"] and body["reconciliation"]["explanation"] + + +# -- the operation ledger --------------------------------------------------- + +def test_the_key_is_over_what_the_call_does_not_over_an_id_somebody_assigned(): + a = operation_key("sess", "s0", "rewrite section 0", 10) + assert a == operation_key("sess", "s0", " rewrite section 0 ", 10) + assert a != operation_key("other", "s0", "rewrite section 0", 10) + assert a != operation_key("sess", "s0", "rewrite section 0", 11) + + +def test_an_applied_step_is_not_repeated_and_not_billed_again(fake, tmp_path): + """The failure this exists for: 'I got the boundary wrong. Paid for those + twice.' SuperDocs has no idempotency on billable writes (BUG-002), so this + is the only place it can be prevented.""" + path = tmp_path / "ops.jsonl" + work = steps(2) + + first = agent(fake, ledger=OperationLedger(path)).run("sess", "d.html", DOC, work) + assert len(first.completed) == 2 + spent_first = first.receipt.counted + + fake.remaining = 500 + second = agent(fake, ledger=OperationLedger(path)).run("sess", "d.html", DOC, work) + assert second.completed == [] + assert second.already_applied == ["s0", "s1"] + assert second.receipt.counted == 0, "the rerun was charged for work already done" + assert spent_first > 0 + assert "not charged again" in second.plain_language() + + +def test_a_call_that_was_started_and_never_confirmed_is_neither_repeated_nor_forgotten( + fake, tmp_path +): + """The genuinely hard case. Retrying might be charged twice and might apply + the same edit twice; skipping might leave the work undone. There is no safe + automatic answer, so it goes to a person.""" + path = tmp_path / "ops.jsonl" + work = steps(1) + key = operation_key("sess", "s0", work[0].instruction, work[0].sections) + OperationLedger(path).begin(key, session_id="sess", step_id="s0") + + report = agent(fake, ledger=OperationLedger(path)).run("sess", "d.html", DOC, work) + assert report.needs_a_person == ["s0"] + assert report.completed == [] + assert report.receipt.counted == 0 + said = report.plain_language() + assert "never confirmed" in said and "check them" in said + + +def test_a_person_can_resolve_what_the_agent_would_not_decide(fake, tmp_path): + path = tmp_path / "ops.jsonl" + work = steps(1) + key = operation_key("sess", "s0", work[0].instruction, work[0].sections) + ledger = OperationLedger(path) + ledger.begin(key, session_id="sess", step_id="s0") + assert len(ledger.unresolved()) == 1 + + ledger.resolve(key, applied=False, note="the document did not have the edit") + assert ledger.unresolved() == [] + report = agent(fake, ledger=OperationLedger(path)).run("sess", "d.html", DOC, work) + assert report.completed == ["s0"], "a failed call must be repeatable" + + +def test_the_ledger_is_written_before_the_call_not_after(fake, tmp_path): + """If it were written afterwards, a process that died mid-call would leave + no trace of a call that was charged — and the rerun would repeat it.""" + path = tmp_path / "ops.jsonl" + work = steps(1) + + class DiesMidCall(FakeSuperDocs): + def request(self, method, path, **kw): + if "chat/async" in path: + raise KeyboardInterrupt("the process was killed") + return super().request(method, path, **kw) + + with pytest.raises(KeyboardInterrupt): + agent(DiesMidCall(), ledger=OperationLedger(path)).run( + "sess", "d.html", DOC, work) + + # A fresh ledger, as a new process would build it. + assert len(OperationLedger(path).records()) == 1 + + +def test_a_half_written_final_line_does_not_lose_the_whole_ledger(tmp_path): + """What an interrupted run actually leaves behind.""" + path = tmp_path / "ops.jsonl" + ledger = OperationLedger(path) + ledger.applied("k1", ops_charged=1) + with path.open("a") as fh: + fh.write('{"key": "k2", "sta') + + reopened = OperationLedger(path) + assert reopened.get("k1").state is State.APPLIED + assert reopened.get("k2").state is State.UNKNOWN + + +def test_the_last_word_about_a_key_is_the_current_one(tmp_path): + path = tmp_path / "ops.jsonl" + ledger = OperationLedger(path) + ledger.begin("k1") + ledger.applied("k1", ops_charged=1) + assert OperationLedger(path).get("k1").state is State.APPLIED + + +def test_only_calls_that_were_never_accepted_are_repeatable(): + ledger = OperationLedger() + assert ledger.get("never-seen").repeatable + assert ledger.failed("k").repeatable + assert not ledger.begin("k2").repeatable # in flight + assert not ledger.applied("k3").repeatable + + +# -- the budget, handed back -------------------------------------------------- + +def test_the_agent_hands_its_remaining_allowance_back_on_every_result(fake): + a = agent(fake) + a.read_allowance() + hint = a.budget_hint() + assert hint["remaining_operations"] > 0 + assert hint["authoritative"] is True + assert hint["spendable_on_new_edits"] == hint["remaining_operations"] - hint["reserve"] + assert "exports are free" in hint["note"].lower() + + +def test_the_hint_never_presents_an_inferred_number_as_a_confirmed_one(fake): + a = agent(fake) + a.run("sess", "d.html", DOC, steps(1)) + hint = a.budget_hint() + assert hint["authoritative"] in (True, False) + if not hint["authoritative"]: + assert "inferred" in hint["note"] + + +@pytest.fixture() +def fake(): + return FakeSuperDocs() + + +@pytest.fixture() +def fake_tight(): + f = FakeSuperDocs() + f.remaining = 2 + return f diff --git a/use-cases/Priyanshu2425/quota-aware-agent/tests/test_mcp_server.py b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_mcp_server.py new file mode 100644 index 00000000..5e99dd51 --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_mcp_server.py @@ -0,0 +1,168 @@ +"""The MCP surface. Keyless — `dispatch` is callable without an MCP client, +which is deliberate: a surface only testable through a protocol client is a +surface that does not get tested.""" + +import json + +import pytest + +from quota_aware_agent import mcp_server as m +from tests.fake import FakeSuperDocs + +WORK = [ + {"id": "figures", "instruction": "Fix the revenue figures.", "sections": 25, + "severity": "critical"}, + {"id": "footer", "instruction": "Tidy the footer.", "sections": 25, "severity": "low"}, +] + + +@pytest.fixture +def fake(monkeypatch): + f = FakeSuperDocs(remaining=2) + from quota_aware_agent.client import SuperDocsClient + monkeypatch.setattr(m, "_client", lambda: SuperDocsClient(f, sleep=lambda s: None)) + return f + + +@pytest.fixture(autouse=True) +def _ledger_in_a_temp_file(tmp_path, monkeypatch): + """The operation ledger is deliberately persistent -- it exists because a + process died -- so a test that used the real one would write into the + developer's home directory and, worse, would see work a previous test run + had "already applied". Each test gets its own.""" + monkeypatch.setattr(m, "LEDGER_PATH", str(tmp_path / "operations.jsonl")) + + +def test_every_declared_tool_has_a_handler(): + """A tool an agent can see but not call is worse than no tool.""" + declared = {t["name"] for t in m.TOOLS} + assert declared == set(m._HANDLERS) + + +def test_every_tool_schema_is_well_formed(): + for t in m.TOOLS: + assert t["description"].strip() + s = t["inputSchema"] + assert s["type"] == "object" + for req in s.get("required", []): + assert req in s["properties"], f"{t['name']} requires undeclared {req!r}" + # every property is documented or typed well enough to use blind + for name, spec in s["properties"].items(): + assert "type" in spec or "enum" in spec, f"{t['name']}.{name} has no type" + + +def test_check_allowance_is_free_and_authoritative(fake): + out = m.dispatch("check_allowance", {}) + assert out["remaining_operations"] == 2 + assert out["authoritative"] is True + assert [p for _, p in fake.calls] == ["/v1/agents/whoami"] # nothing billable + + +def test_plan_work_spends_nothing_and_changes_nothing(fake): + out = m.dispatch("plan_work", {"steps": WORK}) + assert out["will_run"] == ["figures"] + assert out["will_defer"] == ["footer"] + assert out["fits_completely"] is False + assert "Sized to fit" in out["explanation"] + # planning must not upload, edit, approve or export + assert [p for _, p in fake.calls] == ["/v1/agents/whoami"] + + +def test_plan_and_run_agree_about_what_fits(fake): + """If the MCP path planned differently from the library path, only one of + them would be tested. They share the agent, and this pins that.""" + planned = m.dispatch("plan_work", {"steps": WORK}) + ran = m.dispatch("run_work", { + "session_id": "s", "filename": "d.html", + "document_html": "

x

", "steps": WORK, + }) + assert ran["completed"] == planned["will_run"] + assert ran["deferred"] == planned["will_defer"] + + +def test_run_work_reports_the_trade_off_in_plain_language(fake): + out = m.dispatch("run_work", { + "session_id": "s", "filename": "d.html", + "document_html": "

x

", "steps": WORK, + }) + assert "Left undone" in out["plain_language"] + assert out["allowance_at_start"]["authoritative"] is True + + +def test_an_unknown_tool_is_refused_by_name(): + with pytest.raises(KeyError): + m.dispatch("delete_everything", {}) + + +def test_a_failure_is_returned_to_the_agent_not_raised_at_the_transport(monkeypatch): + """An agent has to be able to read the error. Dropping the connection tells + it nothing it can act on.""" + def boom(): + raise RuntimeError("SUPERDOCS_API_KEY is not set") + monkeypatch.setattr(m, "_client", boom) + + # this is what the MCP call_tool wrapper does + try: + result = m.dispatch("check_allowance", {}) + except Exception as e: + result = {"error": str(e)} + assert "SUPERDOCS_API_KEY" in result["error"] + assert json.dumps(result) # and it survives serialisation + + +def test_the_small_sample_bound_is_reachable_from_the_surface(fake): + fake.remaining = 500 + out = m.dispatch("run_work", { + "session_id": "s", "filename": "d.html", "document_html": "

x

", + "steps": WORK, "max_steps": 1, + }) + assert len(out["completed"]) == 1 + + +# -- protocol level ---------------------------------------------------------- + +def test_the_server_starts_and_advertises_its_tools(): + """The functions above are testable without MCP, which is deliberate — but + a server that imports cleanly and cannot start is worse than one that fails + loudly. The SDK's decorator API was removed in 2.x and this build was + written against it, so `serve()` raised AttributeError on startup while + every other test passed. This drives a real client over stdio.""" + mcp = pytest.importorskip("mcp", reason="the MCP SDK is an optional extra") + import asyncio + import os + import pathlib + import sys + + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + # The package lives under backend/, so that is what has to be on the path. + # Pointing PYTHONPATH at the project root instead made this test pass only + # on a machine where the package was already pip-installed -- which is every + # machine except a stranger's fresh clone, the one case it exists to cover. + root = str(pathlib.Path(__file__).resolve().parents[1] / "backend") + + async def drive(): + params = StdioServerParameters( + command=sys.executable, + args=["-m", "quota_aware_agent.mcp_server"], + env={**os.environ, "PYTHONPATH": root, "SUPERDOCS_API_KEY": ""}, + ) + async with stdio_client(params) as (r, w): + async with ClientSession(r, w) as s: + await s.initialize() + listed = await s.list_tools() + names = {t.name for t in listed.tools} + # every advertised tool carries a usable schema + for t in listed.tools: + assert t.description.strip() + assert t.input_schema["type"] == "object" + # and a call with no key comes back as a readable error, + # not a dropped connection + out = await s.call_tool("check_allowance", {}) + return names, out.content[0].text + + names, text = asyncio.run(drive()) + assert names == {"check_allowance", "plan_work", "run_work", + "resolve_operation"} + assert "SUPERDOCS_API_KEY" in text # the failure is legible to the agent diff --git a/use-cases/Priyanshu2425/quota-aware-agent/tests/test_the_gaps_that_were_found.py b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_the_gaps_that_were_found.py new file mode 100644 index 00000000..c1b0cd4d --- /dev/null +++ b/use-cases/Priyanshu2425/quota-aware-agent/tests/test_the_gaps_that_were_found.py @@ -0,0 +1,360 @@ +"""The nine things this build claimed and did not do. + +Each test here is named for the claim, not for the function, because each one +was a gap between what the README said and what the code did. They are kept in +one file so the next reader can see the class of mistake rather than nine +unrelated regressions: **every one of them is the agent believing a number, or +a state, that it had not actually established.** +""" + +from __future__ import annotations + +import base64 +import socket +import urllib.error + +import pytest + +from quota_aware_agent import QuotaAwareAgent, Step, SuperDocsClient +from quota_aware_agent import mcp_server as m +from quota_aware_agent.budget import Change, estimate +from quota_aware_agent.client import (SuperDocsError, TransportFailure, + provably_never_sent) +from quota_aware_agent.idempotency import OperationLedger, State, operation_key +from quota_aware_agent.policy import Policy, StopReason +from tests.fake import FakeSuperDocs + +DOC = b"

x

" + + +def agent(fake, **kw): + return QuotaAwareAgent(SuperDocsClient(fake, sleep=lambda s: None), **kw) + + +@pytest.fixture +def mcp_fake(monkeypatch): + def _make(**kw): + f = FakeSuperDocs(**kw) + monkeypatch.setattr(m, "_client", + lambda: SuperDocsClient(f, sleep=lambda s: None)) + return f + return _make + + +@pytest.fixture(autouse=True) +def _ledger_in_a_temp_file(tmp_path, monkeypatch): + monkeypatch.setattr(m, "LEDGER_PATH", str(tmp_path / "operations.jsonl")) + + +# -- 1. the price of several small edits ------------------------------------ + +def test_each_step_is_its_own_request_so_each_one_costs_at_least_an_operation(): + """Four five-section edits are four requests, not twenty pooled sections. + + Pooled, they price as one operation and bill as four — so the agent reports + "it fits", starts, and runs out partway through somebody's document. That is + the failure this whole build exists to prevent, arriving through its own + arithmetic. + """ + small = [Change(f"s{i}", sections=5) for i in range(4)] + assert estimate(small, batched=True) == 1 # one request, twenty sections + assert estimate(small, batched=False) == 4 # four requests, one each + assert QuotaAwareAgent.BATCHED is False + + +def test_the_plan_it_quotes_is_the_plan_it_can_afford_to_finish(): + """It must never finish a run in a state its own opening sentence ruled out.""" + fake = FakeSuperDocs(remaining=3, sections_per_edit=5) + report = agent(fake, policy=Policy(reserve=1)).run( + "sess", "f.html", DOC, [Step(f"s{i}", f"do {i}", 5) for i in range(4)]) + + assert "would cost about 4 operation(s)" in report.lines[1] + assert report.completed == ["s0", "s1"] + assert report.deferred == ["s2", "s3"] + # Named up front as deferred, not discovered at the reserve floor mid-run. + assert report.stop_reason is StopReason.COMPLETED + + +# -- 2 & 3. a failure that may still have been billed ----------------------- + +def test_a_call_that_may_have_been_billed_is_never_marked_repeatable(tmp_path): + """A read timeout means the request went out and the answer never came back. + + Marking it FAILED makes it repeatable, and repeating it is the double + billing the ledger exists to prevent. + """ + class TimesOut(FakeSuperDocs): + def request(self, method, path, **kw): + if path == "/v1/chat/async": + raise TimeoutError("the read timed out") + return super().request(method, path, **kw) + + path = tmp_path / "ops.jsonl" + a = agent(TimesOut(remaining=50), policy=Policy(reserve=1), + ledger=OperationLedger(path)) + with pytest.raises(TimeoutError): + a.run("sess", "f.html", DOC, [Step("s0", "do thing", 5)]) + + record = OperationLedger(path).get(operation_key("sess", "s0", "do thing", 5)) + assert record.state is State.IN_FLIGHT + assert not record.repeatable + + +def test_a_call_that_provably_never_arrived_is_repeatable(tmp_path): + """The other half of the same distinction. A refused connection was not + billed, and refusing to retry it would strand work for no reason.""" + class Refused(FakeSuperDocs): + def request(self, method, path, **kw): + if path == "/v1/chat/async": + raise urllib.error.URLError(ConnectionRefusedError(61, "refused")) + return super().request(method, path, **kw) + + path = tmp_path / "ops.jsonl" + a = agent(Refused(remaining=50), policy=Policy(reserve=1), + ledger=OperationLedger(path)) + with pytest.raises(urllib.error.URLError): + a.run("sess", "f.html", DOC, [Step("s0", "do thing", 5)]) + + record = OperationLedger(path).get(operation_key("sess", "s0", "do thing", 5)) + assert record.state is State.FAILED + assert record.repeatable + + +def test_the_default_answer_about_a_failure_is_that_we_do_not_know(): + """Conservative on purpose: the cost of being wrong is not symmetric.""" + assert provably_never_sent(urllib.error.URLError(ConnectionRefusedError())) + assert provably_never_sent(urllib.error.URLError(socket.gaierror())) + assert provably_never_sent(SuperDocsError(422, {})) # rejected, unbilled + assert not provably_never_sent(SuperDocsError(502, {})) # gateway; unknown + assert not provably_never_sent(TimeoutError()) + assert not provably_never_sent(RuntimeError("something else")) + assert provably_never_sent(TransportFailure("x", never_sent=True)) + + +# -- 4. the free call the reserve is a promise about ------------------------ + +class _SaysExhaustedOnEveryResponse(FakeSuperDocs): + """The allowance is gone before the run starts, and the platform says so on + everything — including the calls that do not cost anything.""" + + def request(self, method, path, **kw): + r = super().request(method, path, **kw) + r.body.setdefault("usage", {})["quota_exhausted"] = True + return r + + +class _RunsOutMidRunAndSaysSoOnTheExportToo(FakeSuperDocs): + """The sequence that actually happens: room at whoami, exhausted after the + first edit, and the exhaustion still being reported on the free export.""" + + def request(self, method, path, **kw): + r = super().request(method, path, **kw) + if path == "/v1/documents/export" and self._edits: + r.body.setdefault("usage", {})["quota_exhausted"] = True + return r + + +def test_an_exhausted_allowance_never_blocks_the_export_it_promised(): + """The reserve's whole argument is 'you always end up with a file'. + + Exports are free, so an exhausted allowance has no business refusing one — + and a guarantee that breaks at the moment it is needed is not a guarantee + but a sentence in a README. + """ + fake = _RunsOutMidRunAndSaysSoOnTheExportToo( + remaining=2, exhaust_after=1, sections_per_edit=25) + report = agent(fake, policy=Policy(reserve=0)).run( + "sess", "f.html", DOC, [Step("a", "first", 25), Step("b", "second", 25)]) + + assert report.stop_reason is StopReason.QUOTA_EXHAUSTED + assert ("POST", "/v1/documents/export") in fake.calls + assert any("Exported the document" in l for l in report.lines) + + +def test_the_exhaustion_signal_reaches_the_planner_even_off_a_free_call(): + """whoami is free and is not refused by exhaustion — but what it reports + still has to change what the agent does next, or the signal was cosmetic. + Nothing is uploaded, so there is nothing to export and nothing to clean up.""" + fake = _SaysExhaustedOnEveryResponse(remaining=5, sections_per_edit=25) + a = agent(fake, policy=Policy(reserve=0)) + a.read_allowance() + plan = a.plan([Step("a", "first", 25)]) + + assert plan.publish == [] + assert "the allowance is exhausted" in plan.rationale + + +# -- 5. the plan and the ledger -------------------------------------------- + +def test_a_plan_does_not_quote_a_price_for_work_already_paid_for(mcp_fake): + fake = mcp_fake(remaining=5, sections_per_edit=25) + work = [{"id": "figures", "instruction": "Fix the figures.", "sections": 25}, + {"id": "footer", "instruction": "Tidy the footer.", "sections": 25}] + + m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work}) + + plan = m.dispatch("plan_work", {"steps": work, "session_id": "s1"}) + assert plan["already_applied_by_an_earlier_run"] == ["figures", "footer"] + assert plan["will_run"] == [] + assert plan["full_request_costs"] == 0 + assert plan["priced_against_the_ledger"] is True + + +def test_a_plan_without_a_session_says_it_assumed_a_clean_slate(mcp_fake): + mcp_fake(remaining=5) + plan = m.dispatch("plan_work", {"steps": [ + {"id": "a", "instruction": "Fix the figures.", "sections": 25}]}) + assert plan["priced_against_the_ledger"] is False + assert "assumes none of these steps has been run before" in plan["pricing_note"] + + +def test_a_rerun_of_finished_work_is_not_reported_as_not_fitting(mcp_fake): + """'Nothing fits' would send a caller off to buy allowance it does not need.""" + fake = mcp_fake(remaining=5, sections_per_edit=25) + work = [{"id": "figures", "instruction": "Fix the figures.", "sections": 25}] + m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work}) + + again = m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work}) + assert again["stop_reason"] == StopReason.ALREADY_APPLIED.value + assert again["already_applied_by_an_earlier_run"] == ["figures"] + assert again["receipt"]["operations_charged"] == 0 + # It still walks away with the file. + assert ("POST", "/v1/documents/export") in fake.calls + + +# -- 6. the state the surface could enter and not leave --------------------- + +def test_a_started_and_unconfirmed_step_can_be_closed_out_by_a_person(mcp_fake): + fake = mcp_fake(remaining=5, sections_per_edit=25) + led = OperationLedger(m.LEDGER_PATH) + key = operation_key("s1", "figures", "Fix the figures.", 25) + led.begin(key, session_id="s1", step_id="figures") + + work = [{"id": "figures", "instruction": "Fix the figures.", "sections": 25}] + stuck = m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work}) + assert stuck["started_and_never_confirmed"] == ["figures"] + assert stuck["completed"] == [] + + told = m.dispatch("resolve_operation", { + "session_id": "s1", "step_id": "figures", + "instruction": "Fix the figures.", "sections": 25, "applied": False, + "note": "opened the document; the edit is not there"}) + assert told["resolved"] is True + + after = m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work}) + assert after["completed"] == ["figures"] + + +def test_resolving_something_that_needs_no_resolution_says_so_rather_than_lying(mcp_fake): + mcp_fake(remaining=5) + out = m.dispatch("resolve_operation", { + "session_id": "s1", "step_id": "figures", + "instruction": "Fix the figures.", "sections": 25, "applied": True}) + assert out["resolved"] is False + assert "nothing to resolve" in out["explanation"] + + +# -- 7. refusing a partial run, from the surface ---------------------------- + +def test_refusing_a_partial_run_is_reachable_from_the_surface(mcp_fake): + fake = mcp_fake(remaining=2, sections_per_edit=25) + work = [{"id": "figures", "instruction": "Fix the figures.", "sections": 25, + "severity": "critical"}, + {"id": "footer", "instruction": "Tidy the footer.", "sections": 25, + "severity": "low"}] + + out = m.dispatch("run_work", {"session_id": "s1", "filename": "f.html", + "document_html": "

x

", "steps": work, + "when_it_does_not_fit": "refuse"}) + assert out["stop_reason"] == StopReason.REFUSED_PARTIAL.value + assert out["completed"] == [] + assert ("POST", "/v1/documents/upload") not in fake.calls + + plan = m.dispatch("plan_work", {"steps": work, "when_it_does_not_fit": "refuse"}) + assert plan["will_run"] == [] + + +def test_an_unknown_way_of_not_fitting_names_the_two_that_exist(mcp_fake): + mcp_fake(remaining=5) + with pytest.raises(ValueError, match="'degrade'"): + m.dispatch("plan_work", {"steps": [], "when_it_does_not_fit": "explode"}) + + +# -- 8. a caller who holds a real file -------------------------------------- + +def test_a_real_file_can_be_sent_as_bytes_not_only_as_html(mcp_fake): + fake = mcp_fake(remaining=5, sections_per_edit=25) + docx = b"PK\x03\x04 pretend this is a real docx" + m.dispatch("run_work", { + "session_id": "s1", "filename": "contract.docx", + "document_base64": base64.b64encode(docx).decode(), + "steps": [{"id": "a", "instruction": "Fix it.", "sections": 25}]}) + assert fake.uploaded == ["contract.docx"] + + +def test_two_documents_in_one_call_is_refused_rather_than_one_being_ignored(mcp_fake): + mcp_fake(remaining=5) + with pytest.raises(ValueError, match="not both"): + m._document_bytes("

x

", base64.b64encode(b"x").decode()) + + +def test_no_document_at_all_names_both_ways_to_give_one(mcp_fake): + mcp_fake(remaining=5) + with pytest.raises(ValueError, match="document_base64"): + m._document_bytes("", "") + + +def test_bad_base64_is_named_as_bad_base64_not_as_a_failed_upload(mcp_fake): + mcp_fake(remaining=5) + with pytest.raises(ValueError, match="not valid base64"): + m._document_bytes("", "this is not base64!!!") + + +# -- 9. the extension is what SuperDocs parses by --------------------------- + +def test_a_filename_that_disagrees_with_its_bytes_is_refused_before_it_is_sent(): + """The live API chooses its parser from the extension alone. + + HTML uploaded as `report.docx` comes back `400 Invalid DOCX file: File is + not a zip file` — a message that names neither the cause nor the fix, and + arrives only after a round trip. Verified live 2026-08-20, which is also how + this was found: the demo's own default filename was `contract.docx` holding + HTML, so `--live` could never have worked. + """ + from quota_aware_agent.client import check_upload_name + + with pytest.raises(ValueError, match="not a zip archive"): + check_upload_name("report.docx", b"

x

") + with pytest.raises(ValueError, match="do not begin with '%PDF'"): + check_upload_name("report.pdf", b"

x

") + with pytest.raises(ValueError, match="no file extension"): + check_upload_name("report", b"

x

") + + # And the agreeing cases are not obstructed. + check_upload_name("report.html", b"

x

") + check_upload_name("report.docx", b"PK\x03\x04zip") + check_upload_name("report.pdf", b"%PDF-1.7") + + +def test_the_mismatch_that_would_have_SUCCEEDED_is_refused_too(): + """`.txt` holding a .docx is accepted by the platform and parsed as literal + text. A silent wrong answer is worse than a 400, so it is refused here.""" + from quota_aware_agent.client import check_upload_name + + with pytest.raises(ValueError, match="parsed as literal text"): + check_upload_name("report.txt", b"PK\x03\x04zip") + + +def test_the_fake_refuses_the_same_upload_the_live_api_refuses(): + """A fake that accepts any filename is how this shipped unnoticed.""" + fake = FakeSuperDocs(remaining=50) + r = fake.request("POST", "/v1/documents/upload", + files={"file": ("report.docx", b"

x

")}, + data={"session_id": "s1"}) + assert r.status == 400 + assert "not a zip file" in r.body["detail"] diff --git a/use-cases/Priyanshu2425/word-doc-repair/DESIGN.md b/use-cases/Priyanshu2425/word-doc-repair/DESIGN.md new file mode 100644 index 00000000..031ac3f5 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/DESIGN.md @@ -0,0 +1,85 @@ +# Design — Salvage + +The web page for this build, and only this build. It shares no visual language +with anything else its author has built, deliberately: a person whose file just +broke is not the same person, on the same day, as somebody reviewing a document +at their desk. + +## The world + +A postal recovery notice — what an institution sends you when your item arrived +damaged. A printed wrapper, chevron edge tape, a docket number, a rubber-stamped +verdict, and language that takes responsibility in plain words. + +It was chosen because it carries the product's truth exactly: careful custody of +something that is not ours, an honest account of what survived, and bad news +delivered in the same voice as good news. It is not the SaaS uploader page — no +hero, no feature cards, no dashed rectangle — and it is not a terminal. + +## Colour + +Committed: the institution's blue owns the marks and the actions, red belongs to +damage, and the ground is a cool form stock so the page never reads as warm +stationery. + +| Token | Light | Meaning | +|---|---|---| +| `--stock` / `--sheet` | `#e9ebe6` / `#fcfcfa` | the desk, the notice | +| `--ink` / `--ink-2` / `--ink-3` | `#14171a` / `#494f55` / `#7b8188` | text, secondary, tertiary | +| `--post-blue` | `#173f8a` | the institution: marks, stamps, the download action | +| `--post-red` | `#c2222c` | damage: the failed stamp, losses, refusals | +| `--kept` | `#16603c` | one tick, beside something that survived | + +Red is never used for emphasis or decoration — only for something that was lost +or refused. Dark mode is the same notice under a desk lamp. + +## Type + +The system UI stack, set large: 17px body, 28–38px headline, short measure. One +monospace, used only for the record line — filename, size, docket — because that +is a form field, not a costume. + +## Devices with a fixed meaning + +- **Chevron tape** — edges anything in our custody: the notice itself, and the + panel holding the repaired file. Nothing else. +- **The stamp** — one verdict, landing once with a small rotation: *Recovered*, + *Recovered in part*, or *Could not be repaired*. +- **Stations** — the engine's real stages, shown while it works, then folded + behind a disclosure once the verdict is known. The answer outranks the working. +- **The record line** — file, size, docket. A receipt, so the page has a name for + the thing it is holding. +- **The second offer** — the styled copy, ruled off below the handover rather + than boxed in its own tape. Tape means *in our custody*, and there is only one + custody; a second taped panel would read as a second document. It is always + below the download and never above it, because the plain file is the promise + and this is the extra. +- **The sheet** — the rebuilt document itself, rendered for reading, between the + losses and the handover. It carries no chevron tape: tape means *in our + custody* and edges only the notice and the panel holding the file, and this is + the contents rather than the custody. It sits above the download because the + question it answers is asked before the download, not after. + +## Rules that are not style + +- A failure renders no "what came through" section and no download. (BUG-012.) +- Losses are set at the same weight as recoveries. Styling losses as a footnote + would be the overclaim this build refuses, expressed in CSS. +- Retention is stated where the file is handed over, before it can expire. +- A step this copy of the page cannot take is never rendered as a button. It is + either genuinely available or it is one quiet sentence saying it is off and + that nothing else is affected. +- The styled copy is never automatic and never replaces the plain one. Both + downloads stay on the page together. +- The verdict is a claim and the sheet is the thing itself. Where both are on + screen the sheet is never smaller than the claim about it. +- No engine vocabulary reaches the page — not in a stage line, not in a list, not + in an error. Enforced over rendered output, for every fixture. (BUG-014, BUG-020.) +- No sentence claims a complete, perfect or guaranteed repair. Also enforced over + rendered output, with negations understood: *"not a complete repair"* is the + sentence this product exists to say. + +## Mobile + +Designed at 400px first. One column throughout, controls at full width, the +verdict and the download reachable without a horizontal thought. diff --git a/use-cases/Priyanshu2425/word-doc-repair/LICENSE b/use-cases/Priyanshu2425/word-doc-repair/LICENSE new file mode 100644 index 00000000..cb00ec18 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Priyanshu Semwal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/use-cases/Priyanshu2425/word-doc-repair/README.md b/use-cases/Priyanshu2425/word-doc-repair/README.md new file mode 100644 index 00000000..884929dd --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/README.md @@ -0,0 +1,307 @@ +# Salvage — repair my broken Word doc + +**Assigned build B · band S2 · API + export · SuperDocs** + +A `.docx` that will not open leaves its owner with nothing — Word says the file +is corrupt and offers no way forward. Drop it on this page and you get back a +clean, styled Word file, plus a plain account of what came through and what did +not. + +![Salvage's report after recovering a truncated document: a stamped verdict, what came through, and the repaired file](screenshot.png) + +## What it does + +It opens the file the way Word will not, takes out whatever is still readable, +and rebuilds a valid document around it. + +A `.docx` is a ZIP of XML parts, and it breaks in a handful of recognisable +ways. Each one needs a different move: + +| What is wrong | What it does about it | +|---|---| +| The file's index is damaged or the download was cut short | Reads the internal file headers directly and inflates each part by hand. The index lives at the *end* of the file, so it is the first thing a truncated download loses — while the content itself is usually still there. | +| The document body is malformed — a stray `&`, an invalid character, a tag left open by the cut | Repairs each fault, then closes any elements still open at the end. It never reorders or invents content. | +| Structural parts are missing | Regenerates them. They are standard plumbing and carry none of your content, which is why the report does not list them as a loss. | +| Your pictures are in there somewhere | Carries them back into the rebuilt file. Images are separate members of the archive with their own headers, so they survive exactly the damage that destroys the index — and when the part that said *where* each one belonged did not survive, they go at the end under a heading rather than being placed somewhere plausible and wrong. | +| Footnotes, headers and footers | Recovered as text and set down at the end, each under its own heading. A footnote folded silently into the body would change what the document says. | +| The body is too damaged to parse at all | Falls back to pulling the text out run by run — and **says so**, because you are getting words back without headings or tables, and finding that out later is the failure this tool exists to prevent. | +| The main document part is gone entirely | Says it cannot be repaired. Nothing is invented to fill the gap. | + +## What it promises, and what it does not + +It never claims a complete repair. A test asserts that — it fails the build if +the words *fully repaired*, *guaranteed*, *perfect* or *100%* appear anywhere in +any output the user can see. + +**And it shows you the document before you take it.** The rebuilt file is +rendered on the page — headings, tables, and the pictures — above the download +button, because the question *was any of this worth it* is one people ask before +they act. This is the part of the page whose honesty needs no wording at all: + +> It ran for a bit and then wanted forty dollars to download the result. It just +> said "repair successful". I didn't know if it had actually got anything. + +**And it checks the styled copy before offering it.** A styling pass has no +reason to change a single word, so the file that comes back is compared against +what went out, word for word, and thrown away if the wording moved at all. This +is not theoretical: on the first live run, a four-line recovered report came +back with three invented paragraphs, a subtotal row, a disclaimer and a +signature block. It opened cleanly and read better than the plain rebuild, and +it was partly fiction — which for a recovery tool is the worst output there is, +because its owner would not notice. + +Three things it will always do: + +- **Name what it lost.** Structure that could not be preserved, parts cut short + by the damage, content that was not there to recover. +- **Refuse to call an empty file a success.** A valid document with nothing in it + is the most dangerous possible output — it opens cleanly, so the owner may not + notice their content is gone for weeks. That is reported as a failure. +- **Tell you to keep the original.** It is on the result screen, every time. + +## What it uses from SuperDocs + +| Surface | Used for | +|---|---| +| **REST API** — `POST /v1/documents/upload` | Sending the recovered structure up as clean HTML | +| **Chat editing** — `POST /v1/chat/async` | One edit instruction: restore formatting, change no content | +| **Human-in-the-loop approval** — `POST /v1/chat/{session_id}/approve` | Approving each proposed change, with the second parse its payload requires | +| **Export** — `POST /v1/documents/export` | Getting a styled `.docx` back | + +All four are reachable from the page: once the plain file is downloadable, a +**Send it for styling** button offers a second, styled copy. It is never +automatic — someone whose document just broke should not have it sent to a +third-party service because a page decided that for them — and the plain rebuild +is already in their hands before the button exists. + +The local rebuild is the default and needs no key, no network and no account — +and **every failure on the SuperDocs path degrades back to it**, which a test +asserts for each failure in turn. Details under +[Where SuperDocs fits](#where-superdocs-fits). + +## Set it up + +**Requirements.** Python 3.10 or newer, and nothing else. Node is needed only to +change the page — the built bundle is committed, so running it needs no Node and +no network. + +``` +git clone && cd use-cases/Priyanshu2425/word-doc-repair + +python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[web,dev]" + +python3 -m docrepair.web # http://127.0.0.1:8000 +``` + +Open the address it prints and drop a damaged `.docx` on the page. That is the +whole product; everything below is optional. + +| | | +|---|---| +| `PORT=8077 python3 -m docrepair.web` | if 8000 is taken | +| `SUPERDOCS_API_KEY=sk_… python3 -m docrepair.web` | also offers the styled copy (see [Where SuperDocs fits](#where-superdocs-fits)) | +| no key set | the page says styling is off, in a line, and the rest works exactly as before | + +Nothing is written to disk and nothing is retained: uploads and repaired files +are held in memory for one download window and then dropped. + +Have a folder of them rather than one? The CLI is the same engine: + +``` +python3 backend/cli.py broken.docx +python3 backend/cli.py broken.docx -o fixed.docx +python3 backend/cli.py *.docx --quiet +python3 backend/cli.py broken.docx --via-superdocs # needs SUPERDOCS_API_KEY +``` + +## Test it + +Three commands, five files. **None of them needs an API key, a network or a +bucket** — every SuperDocs failure and success is driven through an injected +fake, and every DOCX the automated tests use is written by the tests themselves +at run time and then broken in one specific way. (The eight files under +`manual-test/fixtures/` are committed, because those are for testing by hand.) + +``` +python3 -m pytest # 63 pass, 1 skip — needs nothing installed +pip install -e ".[web,dev]" +python3 -m pytest # 76 pass — adds the endpoint tests +cd frontend && npm install && npm test # 46 pass — the page, rendered and driven +``` + +| Suite | Count | What it holds | +|---|---|---| +| `tests/test_repair.py` | 31 | The engine, over a real DOCX broken eight specific ways. The assertion is never "it did not crash": the output is reopened, every required part checked, the body re-parsed, the table compared cell by cell against the original. | +| `tests/test_styled_export.py` | 21 | The four SuperDocs calls, in order, and every way the path can fail — dead network, exhausted allowance, no job id, empty export, a job that never settles, an unreadable balance. Each one must degrade to the plain rebuild. | +| `tests/test_web.py` | 13 | The two endpoints, called the way the page calls them. Skips rather than fails when the web extra is absent. | +| `tests/test_frontend_fixtures.py` | 11 | Records the page's fixtures from real repairs and fails if they drift. | +| `frontend/…/App.test.tsx` | 46 | The whole page rendered, answered with those recorded streams, driven through every path a person can take. | + +**The tests that matter most are the ones about honesty**, and they run over +what is on the screen rather than over the engine's strings: + +- a missing document part **fails**, and says why +- an empty body is a **failure**, not a success with no content +- no user-visible string claims a complete, perfect or guaranteed repair — + with negations understood, because *"not a complete repair"* is the sentence + this build exists to say +- structural parts the tool rebuilds are never reported as lost content +- no engine vocabulary reaches a worried person — no `ZIP`, `XML`, `CRC`, + `central directory`, no exception class name +- a styled copy whose wording changed is **thrown away**, not handed over + +That distinction — rendered output, not engine output — is not pedantry: +BUG-014 was fixed in the engine and came straight back as BUG-020 in the page. + +**Working on the page itself:** + +``` +cd frontend +npm install +npm run dev # vite on 5173, /api proxied to the engine on 8000 + # SALVAGE_API=http://127.0.0.1:8077 npm run dev if you moved it +npm test # 46 +npm run build # rewrites backend/static/index.html AND bundle-manifest.json +``` + +The bundle is committed so a reviewer with no Node still gets the product. The +cost of that decision is that it can fall behind its source and nobody notices, +so the build writes a hash over every source file and a pytest recomputes it. +**Stale bundle, failed build** — if you change anything under `frontend/src`, +run `npm run build` before committing. + +**Checking it by hand.** Three pages, all openable straight from disk: + +| File | What it is | +|---|---| +| `manual-test/index.html` | The interactive checklist — 19 items, verdicts persist across reloads, exports as Markdown | +| `manual-test/MANUAL_QA_PLAN.html` | The record of an actual run against a live server, with what could not be run marked *not run* rather than passed | +| `manual-test/UI_FLOWS.html` | Every path through the page, screen by screen | +| `SYSTEM_DESIGN.html` | How it is built and why — the architecture, the two paths, the failure matrix | + +`manual-test/make_fixtures.py` regenerates the eight broken files in +`manual-test/fixtures/` if you want fresh ones. + +## How it is tested + +Every test starts from a **real** DOCX this package wrote, then breaks it in one +specific way — truncated container, missing content-types part, unclosed tags, +bare ampersands and control characters, missing document part, not a ZIP at all, +empty body, and an illustrated document both whole and cut short. The assertion is not "it did not crash": the output is reopened as a +ZIP, every required part is checked, the body is re-parsed, and the recovered +table is compared cell by cell against the original. + +The tests that matter most are the ones about honesty: + +- a missing document part **fails**, and says why +- an empty body is a **failure**, not a success with no content +- no user-visible string overclaims +- structural parts the tool rebuilds are never reported as lost content +- the user-facing lists contain no jargon — no `ZIP`, `XML`, `CRC`, or + `central directory` leaking out of the engine into a worried person's summary + +## Where SuperDocs fits + +The four-call contract — upload · edit instruction · approve · export — is built +and tested in `backend/docrepair/styled_export.py`, and runs as an optional +second pass. Set a key and the page offers it; the CLI takes a flag: + +``` +export SUPERDOCS_API_KEY=your-key-here +python3 -m docrepair.web # the button appears on the result screen +python3 backend/cli.py broken.docx --via-superdocs +``` + +Without a key the page says so in a line rather than showing a button that +fails when somebody presses it: `GET /api/capabilities` is asked before anything +is offered. + +**The allowance is read before anything is spent.** `GET /v1/agents/whoami` +carries the remaining balance, and a styling pass that cannot finish is refused +before the first billable call rather than discovered halfway through — trap 3, +asked in advance. A balance that cannot be read is *not* treated as a balance of +zero: a personal key is not an agent key, and refusing on a number nobody +managed to read would be its own kind of bluff. + +**And the result is checked, not trusted.** `content_drift` compares the words +that came back against the words that went out. Any addition or removal and the +styled file is discarded with the reason said plainly. The instruction was +tightened at the same time and the same document then came back word for word +identical — but the instruction is the request and the guard is the promise. + +Recovered structure goes out as clean **HTML**, never raw Word XML — the docs are +explicit that there is no endpoint for the latter — so headings stay headings and +tables stay tables. The edit instruction restores formatting and forbids content +changes; each proposed change is approved through the HITL endpoint (with the +second parse the payload requires); the export comes back as a styled DOCX. + +**Every failure on this path degrades to the local rebuild**, and a test asserts +it for each one: a dead network, an exhausted allowance, no job id, an empty +export, or any unexpected exception. The engine has already produced a valid file +before this runs, and nothing here is allowed to take that away from the user. + +**The local rebuild is the default, and that is deliberate.** Someone whose +document is broken should not have to hand it to a third-party service, or wait +on an API, to find out whether anything survived. The offline path answers that +in milliseconds and costs nothing; the SuperDocs path is for producing a +polished, fully-styled export once you know there is something worth styling. + +Session state and uploads are held in memory for one download and then dropped. +Nothing is written to disk and nothing is retained. + +## Honest limitations + +- **The rebuilt file is deliberately plain.** Your original theme, fonts and + spacing cannot be recovered from a broken file, so it rebuilds with clean + headings, tables and body text rather than guessing at a design you had. +- **Pictures come back; their captions and wrapping do not.** An image is placed + inline where the body referenced it, or at the end when nothing survived to + say where it belonged. Floating positions, text wrap and captions are gone. +- **Footnotes, headers and footers come back as text, not as page furniture.** A + rebuild cannot put a footnote back at the foot of the page it belonged to, so + their text is set down at the end under its own heading and the report says so. +- **An image whose header cannot be read is placed at a stated default size** + rather than at a measurement nobody took. Its aspect ratio is left alone. +- **Comments and tracked changes are not recovered.** +- **The styled copy carries the recovered text and nothing else.** Pictures are + not sent for styling: the styling pass takes clean HTML, and an image with no + surviving position is not something a formatting pass can place. The plain + rebuild is the one that has your pictures in it. +- **A styled copy is not always available.** It needs a key, an allowance, and a + result that comes back saying exactly what it was given. Any of the three + missing and you get the plain rebuild and a sentence saying why. +- Direct upload only, so the practical ceiling is about 20 MB. +- `.doc` (the pre-2007 binary format) is not a ZIP at all and is not supported — + it is reported as unrepairable rather than silently mangled. + +## Testing it by hand + +The automated suite proves the engine does the right thing. It cannot tell you +whether the downloaded file opens in Word, whether the progress is perceptible, +or whether a non-technical person understands what they got back. + +`manual-test/index.html` is a checklist for exactly that — open it in a second +tab. Nineteen items: eight deliberately broken fixtures with their expected +results transcribed from real runs, four edge cases, and seven judgement calls +no test can make. Verdicts and notes persist across reloads, and it exports the +results as Markdown. + +Writing it found three defects the suite had missed: a total failure still +rendered a "what came through" claim, one problem was reported twice, and an +exception class name leaked into a stage line. All three are fixed, and the +suite now guards the stage log as well as the summary. + +## Shared code — stated plainly + +`backend/docrepair/superdocs_client.py` is the same four-call client used by the +quota-aware agent (assigned build A). It is vendored into both so each stands +alone in the builds repository. Reuse is only a shortcut when it is hidden. + +## Credit + +Built by **Priyanshu Semwal** ([@Priyanshu2425](https://github.com/Priyanshu2425)) +for the SuperDocs engineer round, 2026. MIT licensed — see [LICENSE](LICENSE). + +Grounded throughout in the SuperDocs API documentation; where the task brief and +the documentation differed, the documentation won. diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/cli.py b/use-cases/Priyanshu2425/word-doc-repair/backend/cli.py new file mode 100644 index 00000000..e275d62a --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/cli.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Same engine as the web page, for people who live in a terminal. + +The web page is the product. This exists because a batch of fifty files is not +a drag-and-drop job, and because a shared engine means the two front doors can +never disagree about what was recovered. + + python3 cli.py broken.docx + python3 cli.py broken.docx -o fixed.docx + python3 cli.py *.docx --quiet + python3 cli.py broken.docx --via-superdocs # also style it through SuperDocs +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from docrepair import repair +from docrepair.docx import read_blocks + + +def _style_through_superdocs(r, quiet: bool): + """Optional second pass. Never allowed to cost the caller the local result.""" + import io + import os + import zipfile + + from docrepair.styled_export import styled_export + from docrepair.superdocs_client import HttpTransport, SuperDocsClient + + key = os.environ.get("SUPERDOCS_API_KEY") + if not key: + print(" SUPERDOCS_API_KEY is not set; keeping the plain rebuild.", file=sys.stderr) + return None + with zipfile.ZipFile(io.BytesIO(r.output)) as z: + blocks = read_blocks(z.read("word/document.xml")) + client = SuperDocsClient(HttpTransport(key)) + res = styled_export( + client, f"repair-{os.getpid()}", blocks, + on_progress=None if quiet else lambda s, m: print(f" {m}", file=sys.stderr), + ) + return res + + +def one(path: Path, out: Path | None, quiet: bool, via_superdocs: bool = False) -> bool: + data = path.read_bytes() + r = repair(data, path.name, + on_progress=None if quiet else lambda s, m: print(f" {m}", file=sys.stderr)) + + print(f"\n{path.name}") + print(f" {r.summary()}") + for line in r.recovered: + print(f" + {line}") + for line in r.lost: + print(f" - {line}") + + if not r.ok: + return False + + blob = r.output + if via_superdocs: + styled = _style_through_superdocs(r, quiet) + if styled and styled.ok: + blob = styled.output + qualifier = "" if styled.ops_confirmed else " estimated" + unit = "operation" if styled.ops_charged == 1 else "operations" + print(f" + styled through SuperDocs ({styled.ops_charged}{qualifier} {unit})") + elif styled: + for n in styled.notes[-1:]: + print(f" · {n}") + + dest = out or path.with_name(r.filename) + dest.write_bytes(blob) + print(f" → {dest}") + return True + + +def main(argv=None) -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("files", nargs="+", type=Path) + p.add_argument("-o", "--out", type=Path, help="output path (single input only)") + p.add_argument("-q", "--quiet", action="store_true", help="hide per-stage progress") + p.add_argument("--via-superdocs", action="store_true", + help="also style the result through SuperDocs (needs SUPERDOCS_API_KEY)") + a = p.parse_args(argv) + + if a.out and len(a.files) > 1: + p.error("--out takes a single input file") + + failures = 0 + for f in a.files: + if not f.exists(): + print(f"{f}: no such file", file=sys.stderr) + failures += 1 + continue + if not one(f, a.out, a.quiet, a.via_superdocs): + failures += 1 + + if failures: + print(f"\n{failures} of {len(a.files)} file(s) could not be repaired.", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/__init__.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/__init__.py new file mode 100644 index 00000000..fdd8740b --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/__init__.py @@ -0,0 +1,4 @@ +from .engine import Repair, repair +from .docx import Block, read_blocks, write_docx, blocks_to_html + +__all__ = ["Repair", "repair", "Block", "read_blocks", "write_docx", "blocks_to_html"] diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/docx.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/docx.py new file mode 100644 index 00000000..82d11634 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/docx.py @@ -0,0 +1,317 @@ +"""Read structure out of a damaged document part, and write a valid one back. + +The writer matters as much as the reader. A tool that salvages text and hands +back a .txt has not repaired anything -- the owner wanted a Word file. So this +builds a real DOCX: a correct `[Content_Types].xml`, the two relationship +parts, a stylesheet with actual heading styles, and a document body. + +The output is deliberately plain. Recovering someone's original theme is not +possible from a broken file, and pretending otherwise would be the "silent +claim of a full fix" the brief warns against. What it guarantees is a file that +opens, with headings that are headings and tables that are tables. +""" + +from __future__ import annotations + +import base64 +import re +import zipfile +from dataclasses import dataclass +from xml.etree import ElementTree as ET + +from . import media +from .media import Image +from .salvage import W + +def content_types(extensions: list[str] | None = None) -> str: + """Word will not open a package whose parts have no declared type, so an + image extension carried into the rebuild has to be declared here as well as + written into the archive. Missing this produces a file that opens on some + machines and is called corrupt on others, which is the worst of the three + possible outcomes.""" + defaults = "".join( + f'' + for e in sorted(set(extensions or [])) if e in media.CONTENT_TYPES + ) + return ( + '' + '' + '' + '' + + defaults + + '' + '' + "" + ) + + +CONTENT_TYPES = content_types() + +ROOT_RELS = """ + + +""" + +IMAGE_REL = ("http://schemas.openxmlformats.org/officeDocument/2006/" + "relationships/image") + + +def doc_rels(images: list[tuple[str, str]] | None = None) -> str: + """`rId1` is always the stylesheet; images take rId2 upwards.""" + extra = "".join( + f'' + for rid, target in (images or []) + ) + return ( + '' + '' + '' + + extra + "" + ) + + +DOC_RELS = doc_rels() + + +def _style(sid: str, name: str, size: int, bold: bool, outline: int | None) -> str: + outline_xml = f'' if outline is not None else "" + return ( + f'' + f'' + f'{outline_xml}' + f'' + f'{"" if bold else ""}' + ) + + +STYLES = ( + '' + '' + '' + '' + '' + + _style("Normal", "Normal", 22, False, None) + + _style("Title", "Title", 56, True, 0) + + _style("Heading1", "heading 1", 32, True, 0) + + _style("Heading2", "heading 2", 26, True, 1) + + _style("Heading3", "heading 3", 24, True, 2) + + "" +) + + +@dataclass +class Block: + kind: str # "heading" | "paragraph" | "table" | "image" + text: str = "" + level: int = 1 + rows: list[list[str]] | None = None + image: Image | None = None + + +def read_blocks(document_xml: bytes, targets: dict[str, str] | None = None, + images: dict[str, Image] | None = None) -> list[Block]: + """Parse the body into blocks, keeping the structure that survived. + + `targets` and `images` are what makes a picture land back where it was: the + relationship map says which file a drawing points at, and without it a + drawing is a reference to nothing. Both optional, because both parts can be + damaged, and a document with unplaceable images is still a document. + """ + targets = targets or {} + images = images or {} + root = ET.fromstring(document_xml) + body = root.find(f"{W}body") + if body is None: + return [] + + blocks: list[Block] = [] + for el in body: + if el.tag == f"{W}p": + for rid in media.embedded_ids(el): + img = images.get(targets.get(rid, "")) + if img is not None: + blocks.append(Block("image", text=img.name, image=img)) + text = _para_text(el) + if not text.strip(): + continue + level = _heading_level(el) + blocks.append( + Block("heading", text, level) if level else Block("paragraph", text) + ) + elif el.tag == f"{W}tbl": + rows = [] + for tr in el.findall(f"{W}tr"): + rows.append([_cell_text(tc) for tc in tr.findall(f"{W}tc")]) + if rows: + blocks.append(Block("table", rows=rows)) + return blocks + + +def _para_text(p: ET.Element) -> str: + return "".join(t.text or "" for t in p.iter(f"{W}t")) + + +def _cell_text(tc: ET.Element) -> str: + return " ".join(_para_text(p) for p in tc.findall(f"{W}p")).strip() + + +def _heading_level(p: ET.Element) -> int | None: + ppr = p.find(f"{W}pPr") + if ppr is None: + return None + style = ppr.find(f"{W}pStyle") + if style is None: + return None + val = style.get(f"{W}val", "") + m = re.fullmatch(r"[Hh]eading\s*([1-9])", val) + if m: + return int(m.group(1)) + if val.lower() in {"title"}: + return 1 + return None + + +def _esc(s: str) -> str: + return (s.replace("&", "&").replace("<", "<").replace(">", ">")) + + +def _p(text: str, style: str | None = None) -> str: + ppr = f'' if style else "" + return f"{ppr}{_esc(text)}" + + +def _table(rows: list[list[str]]) -> str: + out = [ + '' + '' + '' + + "".join( + f'' + for e in ("top", "left", "bottom", "right", "insideH", "insideV") + ) + + "" + ] + for row in rows: + out.append("") + for cell in row: + out.append( + '' + + _p(cell) + "" + ) + out.append("") + out.append("") + return "".join(out) + + +def image_blocks(blocks: list[Block]) -> list[Block]: + return [b for b in blocks if b.kind == "image" and b.image is not None] + + +def _rel_ids(blocks: list[Block]) -> dict[str, str]: + """One relationship id per distinct image, starting after the stylesheet. + + Keyed by member name rather than by position, so the same picture used twice + is carried once and referenced twice — which is how the original stored it. + """ + ids: dict[str, str] = {} + for b in image_blocks(blocks): + ids.setdefault(b.image.name, f"rId{len(ids) + 2}") + return ids + + +def build_document_xml(blocks: list[Block]) -> str: + rel_ids = _rel_ids(blocks) + body = [] + for n, b in enumerate(blocks, 1): + if b.kind == "image" and b.image is not None: + body.append(media.drawing_xml(rel_ids[b.image.name], b.image, n, + b.image.name.rsplit("/", 1)[-1])) + elif b.kind == "heading": + body.append(_p(b.text, f"Heading{min(max(b.level, 1), 3)}")) + elif b.kind == "table" and b.rows: + body.append(_table(b.rows)) + body.append(_p("")) + else: + body.append(_p(b.text)) + return ( + '' + '' + "" + "".join(body) + + '' + '' + "" + ) + + +def write_docx(blocks: list[Block]) -> bytes: + import io + + rel_ids = _rel_ids(blocks) + by_name = {b.image.name: b.image for b in image_blocks(blocks)} + extensions = [img.ext for img in by_name.values()] + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + # mimetype-equivalent ordering is not required for OOXML, but the + # content types part must be present and first is conventional. + z.writestr("[Content_Types].xml", content_types(extensions)) + z.writestr("_rels/.rels", ROOT_RELS) + # document.xml goes early, as Word writes it. The order is not + # cosmetic: a truncated download loses the END of the file, so the part + # carrying the content is the one you want furthest from the cut. + z.writestr("word/document.xml", build_document_xml(blocks)) + z.writestr("word/_rels/document.xml.rels", + doc_rels([(rel_ids[n], n.split("word/", 1)[-1]) + for n in by_name])) + z.writestr("word/styles.xml", STYLES) + for name, img in by_name.items(): + # Already compressed. Deflating a PNG again costs time and saves + # nothing, and this runs while somebody is watching a progress bar. + z.writestr(name, img.data, zipfile.ZIP_STORED) + return buf.getvalue() + + +def blocks_to_html(blocks: list[Block], inline_images: bool = False, + image_budget: int = 3_000_000) -> str: + """For the SuperDocs path, which takes documents and HTML, never raw + Word XML -- the docs are explicit that there is no endpoint for that. + + `inline_images` is for the preview the page shows before anybody downloads + anything: the pictures have to be visible for the preview to answer the + question it exists to answer. They are embedded as data URIs up to a budget, + and past it the image is named rather than shown -- an image that silently + fails to load in a preview would read as an image that was not recovered. + """ + out = [] + spent = 0 + for b in blocks: + if b.kind == "image" and b.image is not None: + if not inline_images: + continue + if spent + len(b.image.data) > image_budget: + out.append( + '

An image was recovered and is in ' + "the file. It is not shown here because the preview would be " + "too large to load.

" + ) + continue + spent += len(b.image.data) + src = ("data:" + b.image.content_type + ";base64," + + base64.b64encode(b.image.data).decode("ascii")) + out.append(f'A picture recovered from your document') + elif b.kind == "heading": + lvl = min(max(b.level, 1), 3) + out.append(f"{_esc(b.text)}") + elif b.kind == "table" and b.rows: + rows = "".join( + "" + "".join(f"{_esc(c)}" for c in r) + "" + for r in b.rows + ) + out.append(f"{rows}
") + else: + out.append(f"

{_esc(b.text)}

") + return "\n".join(out) diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/engine.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/engine.py new file mode 100644 index 00000000..1b1bc605 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/engine.py @@ -0,0 +1,376 @@ +"""The repair, as stages someone can watch, and a report they can read. + +The stages are real branch points, not labels. What happens at each one depends +on what the previous one found, and the report says which path was taken -- +because "we recovered your text but lost your tables" and "we recovered +everything" should not look the same to the person who gets the file back. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from . import docx, media, salvage +from .salvage import plural + +Progress = Callable[[str, str], None] # (stage, human-readable message) + +DOCUMENT_PART = "word/document.xml" +DOC_RELS_PART = "word/_rels/document.xml.rels" + +#: Parts that carry a person's words but are not the body of the document. +#: Each is recovered as text and set down at the end under its own heading, +#: because a rebuild cannot put a footnote back at the foot of the page it +#: belonged to -- and a footnote silently folded into the body would change what +#: the document says. +ASIDE_PARTS: list[tuple[str, str]] = [ + ("word/footnotes.xml", "Footnotes"), + ("word/endnotes.xml", "Endnotes"), + ("word/header", "Page header"), + ("word/footer", "Page footer"), +] + +#: Boilerplate Word writes into every footnotes part whether or not the document +#: has footnotes. Carrying it into the rebuild would invent an aside that never +#: existed. +_EMPTY_ASIDE = {"", "-", "\u2014"} + + +@dataclass +class Repair: + ok: bool = False + output: bytes = b"" + filename: str = "repaired.docx" + recovered: list[str] = field(default_factory=list) + lost: list[str] = field(default_factory=list) + stages: list[tuple[str, str]] = field(default_factory=list) + structure_preserved: bool = True + counts: dict[str, int] = field(default_factory=dict) + #: The rebuilt document rendered for reading, pictures included. Shown + #: before the download, never instead of it. + preview_html: str = "" + #: Whether the file's own index had to be gone around. Carried on the report + #: rather than passed between stages, because the stage that discovers it and + #: the stage that is allowed to say it are not the same one. + container_was_damaged: bool = False + + def as_payload(self, download: str | None = None, + style: str | None = None) -> dict: + """The one shape this result takes on the wire. + + Here rather than in `web.py` because two callers need it — the endpoint + and the pytest that records the page's fixtures — and a hand-copied + second version is how a fixture drifts from the thing it imitates while + still claiming to be captured from it. That is BUG-015's shape, and the + capture's own docstring says *recorded exactly as the web endpoint + streams it*, which nothing was holding it to. + """ + return { + "ok": self.ok, + "summary": self.summary(), + "recovered": self.recovered, + "lost": self.lost, + "counts": self.counts, + "structure_preserved": self.structure_preserved, + "filename": self.filename, + "preview_html": self.preview_html, + "download": download if self.ok else None, + # Where the optional styling pass can be started for this result. + # Null on a failure for the same reason `download` is: there is + # nothing to style, and offering the button would be a claim. + "style": style if self.ok else None, + } + + def summary(self) -> str: + """Plain language. Never claims a complete repair.""" + if not self.ok: + reason = self.lost[0] if self.lost else "no readable content was found inside it" + reason = reason[0].upper() + reason[1:] + if not reason.endswith("."): + reason += "." + return ( + f"This file could not be repaired. {reason} " + "Nothing was invented to fill the gap — if there is another copy, " + "even an older one, that is the better starting point." + ) + head = ( + "Recovered what could be read and rebuilt it as a valid Word file. " + "This is a best-effort recovery, not a complete repair." + ) + if not self.structure_preserved: + head += ( + " The document's structure was too damaged to read, so the text was " + "extracted run by run — you are getting the words back, but the " + "headings, tables and formatting are gone." + ) + return head + + +class _Stop(Exception): + """A stage decided the repair cannot go further. + + Raised rather than returned so each stage can refuse from wherever it + discovers the refusal, and `repair` stays a narrative of the happy path with + the refusals visible in one place. Every one of them has already written its + reason into the report before it raises. + """ + + +def repair(data: bytes, filename: str = "document.docx", + on_progress: Progress | None = None) -> Repair: + """The whole repair, as the sequence of decisions it actually is. + + Each stage is its own function, because each one is a different judgement + about somebody's document and they are worth reading separately. What stays + here is the order, which is the part that has to be right: nothing is + claimed as recovered before it has been read, and nothing is written before + everything that could refuse has had its chance to. + """ + r = Repair(filename=_repaired_name(filename)) + + def stage(name: str, msg: str) -> None: + r.stages.append((name, msg)) + if on_progress: + on_progress(name, msg) + + try: + s = _open_container(data, r, stage) + _take_inventory(s, r, stage) + pictures = media.collect(s.members) + blocks, structure_read = _read_body(s, pictures, r, stage) + unplaced = _place_pictures(blocks, pictures, r, stage) + asides = _read_asides(s.members, stage) + blocks.extend(asides.blocks) + _count_what_came_through(blocks, r, stage, structure_read) + _account_for_pictures(pictures, unplaced, asides, r) + except _Stop: + return r + + stage("write", "Rebuilding a clean Word file…") + r.output = docx.write_docx(blocks) + # What is actually in the file, so somebody can look before they decide + # whether it was worth anything. From the research, on a paid repair tool: + # "It just said 'repair successful'. I didn't know if it had actually got + # anything." A claim with nothing behind it is what breaks trust. + r.preview_html = docx.blocks_to_html(blocks, inline_images=True) + r.ok = True + stage("done", "Done. The rebuilt file is ready to download.") + return r + + +def _open_container(data: bytes, r: Repair, stage: Progress) -> salvage.Salvage: + """Get at the parts, going around the index rather than through it.""" + stage("open", "Opening the file…") + s = salvage.salvage_members(data) + for note in s.notes: + stage("open", _sentence(note)) + # The stage log carries every diagnostic. "What came through" is the list a + # worried person reads, so it gets outcomes -- what happened to their + # content -- and not a narration of how the file was opened. The one line + # about the damaged index is held back until there is recovered content to + # attach it to, because on a total failure it would be an overclaim. + r.container_was_damaged = any("internal index was damaged" in n for n in s.notes) + r.lost.extend(s.unrecovered) + + if not s.members: + stage("open", "No readable parts were found inside the file.") + # `salvage` has already said this if it found nothing; saying it twice + # reads as two separate problems. + if not any("no readable parts" in l for l in r.lost): + r.lost.append("the file contained no readable parts") + raise _Stop + return s + + +def _take_inventory(s: salvage.Salvage, r: Repair, stage: Progress) -> None: + """What is here, what can be rebuilt, and what cannot be done without.""" + stage("inventory", f"Found {plural(len(s.members), 'section')} inside the document.") + missing = [p for p in ("[Content_Types].xml", "_rels/.rels", DOCUMENT_PART) + if p not in s.members] + rebuildable = [p for p in missing if p != DOCUMENT_PART] + if rebuildable: + stage("inventory", + f"Rebuilding {plural(len(rebuildable), 'missing structural part')}…") + r.recovered.append( + "rebuilt the file's internal structure, which was missing or damaged — " + "this is standard plumbing and carries none of your content" + ) + + if DOCUMENT_PART not in s.members: + stage("inventory", "The main document part is missing and cannot be rebuilt.") + r.lost.append( + "the main document part is missing entirely, so there is no text to recover" + ) + raise _Stop + + +def _read_body(s: salvage.Salvage, pictures: dict[str, media.Image], r: Repair, + stage: Progress) -> tuple[list[docx.Block], bool]: + """Repair the body, then read structure out of it — or fall back and say so.""" + stage("xml", "Checking the document body for damage…") + fixed, notes = salvage.repair_xml(s.members[DOCUMENT_PART]) + for n in notes: + stage("xml", _sentence(n)) + r.recovered.extend(notes) + if not notes: + stage("xml", "The document body was well-formed.") + + stage("read", "Reading headings, paragraphs and tables…") + targets = media.relationship_targets(s.members.get(DOC_RELS_PART)) + try: + blocks = docx.read_blocks(fixed, targets, pictures) + r.structure_preserved = True + except Exception: + stage("read", "The document's structure was too damaged to read. " + "Recovering the text on its own instead.") + blocks = [docx.Block("paragraph", t) + for t in salvage.text_runs(fixed) if t.strip()] + r.structure_preserved = False + r.lost.append( + "the document structure was unreadable, so headings, tables and " + "formatting could not be preserved — only the text was recovered" + ) + + if not blocks and not pictures: + stage("read", "No readable content was found in the document body.") + r.lost.append("the document body contained no readable text") + raise _Stop + return blocks, r.structure_preserved + + +def _place_pictures(blocks: list[docx.Block], pictures: dict[str, media.Image], + r: Repair, stage: Progress) -> list[media.Image]: + """Collect at the end the pictures the body could not be shown to reference. + + Either the part naming which file each reference points at did not survive, + or the text fell back to a run-by-run read that carries no positions. They + are still the owner's pictures, so they go at the end — an image silently + placed in the wrong paragraph is worse than one obviously placed last. + """ + placed = {b.image.name for b in docx.image_blocks(blocks)} + unplaced = [img for name, img in sorted(pictures.items()) if name not in placed] + if unplaced: + stage("read", f"Recovered {plural(len(unplaced), 'picture')} " + "whose original position could not be worked out.") + blocks.append(docx.Block("heading", "Pictures recovered from this document", + level=1)) + blocks.extend(docx.Block("image", text=img.name, image=img) for img in unplaced) + return unplaced + + +def _count_what_came_through(blocks: list[docx.Block], r: Repair, stage: Progress, + structure_read: bool) -> None: + r.counts = { + "headings": sum(1 for b in blocks if b.kind == "heading"), + "paragraphs": sum(1 for b in blocks if b.kind == "paragraph"), + "tables": sum(1 for b in blocks if b.kind == "table"), + "pictures": len(docx.image_blocks(blocks)), + } + parts = [plural(r.counts[k], k[:-1]) + for k in ("headings", "paragraphs", "tables", "pictures") + if r.counts[k]] + tally = parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + f" and {parts[-1]}" + stage("read", f"Recovered {tally}.") + if r.container_was_damaged: + r.recovered.insert(0, "read your content out of a file whose internal index " + "was damaged — the damage Word refuses to open") + if structure_read: + r.recovered.append(f"kept {tally}, with their structure intact") + else: + r.recovered.append(f"recovered the text of {plural(len(blocks), 'paragraph')}") + + +def _account_for_pictures(pictures: dict[str, media.Image], + unplaced: list[media.Image], asides: "Asides", + r: Repair) -> None: + """Say where the pictures and the asides ended up, and where they did not.""" + placed = len(pictures) - len(unplaced) + if placed: + r.recovered.append( + f"carried {plural(placed, 'picture')} back into the document, " + + ("where they were" if placed > 1 else "where it was") + ) + if unplaced: + r.recovered.append( + f"recovered {plural(len(unplaced), 'picture')} and set " + f"{'them' if len(unplaced) > 1 else 'it'} at the end, under a heading" + ) + them = "they are" if len(unplaced) > 1 else "it is" + belonged = "they belonged" if len(unplaced) > 1 else "it belonged" + r.lost.append( + f"the original position of {plural(len(unplaced), 'picture')} could not " + f"be recovered, so {them} collected at the end rather than put back " + f"where {belonged}" + ) + for label, count in asides.recovered: + r.recovered.append( + f"recovered the text of {plural(count, label.lower())}" + if count > 1 else f"recovered the {label.lower()}" + ) + if asides.blocks: + r.lost.append( + "footnotes, headers and footers could not be put back into their " + "original places, so their text is set down at the end of the " + "document under its own heading" + ) + for label in asides.unreadable: + r.lost.append(f"the {label.lower()} could not be read") + + +def _sentence(text: str) -> str: + text = text[0].upper() + text[1:] + return text if text.endswith((".", "…", "!", "?")) else text + "." + + +def _repaired_name(filename: str) -> str: + stem = filename.rsplit("/", 1)[-1] + if stem.lower().endswith(".docx"): + stem = stem[:-5] + return f"{stem}-repaired.docx" + + +@dataclass +class Asides: + """Text that belongs to the document but not to its body.""" + + blocks: list[docx.Block] = field(default_factory=list) + recovered: list[tuple[str, int]] = field(default_factory=list) + unreadable: list[str] = field(default_factory=list) + + +def _read_asides(members: dict[str, bytes], stage: Progress) -> Asides: + """Footnotes, endnotes, headers and footers, as text under their own heading. + + They live in their own parts, so they survive exactly the damage that + destroys the index — the same reason the pictures do. Dropping them was + losing content that was sitting right there. + + What this deliberately does not do is fold them into the body. A footnote + read as a paragraph changes what the document says, and a page header + repeated as body text reads as something the author wrote. They are set + down at the end, labelled, and the report says they are not where they were. + """ + out = Asides() + for prefix, label in ASIDE_PARTS: + names = sorted(n for n in members + if n == prefix or (prefix.endswith(("header", "footer")) + and n.startswith(prefix) + and n.endswith(".xml"))) + texts: list[str] = [] + for name in names: + try: + fixed, _ = salvage.repair_xml(members[name]) + runs = [t.strip() for t in salvage.text_runs(fixed) if t.strip()] + except Exception: + out.unreadable.append(label) + continue + texts.extend(t for t in runs if t not in _EMPTY_ASIDE) + if not texts: + continue + stage("read", f"Recovering the {label.lower()}…") + out.blocks.append(docx.Block("heading", f"{label}, recovered separately", + level=2)) + out.blocks.extend(docx.Block("paragraph", t) for t in texts) + out.recovered.append((label, len(texts))) + return out diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/media.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/media.py new file mode 100644 index 00000000..ff128cd1 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/media.py @@ -0,0 +1,243 @@ +"""Getting the pictures back. + +A `.docx` keeps its images as ordinary members of the ZIP — `word/media/image1.png` +and so on — each with its own local file header, entirely separate from +`word/document.xml`. Which means they survive exactly the damage that destroys +the index, and a rebuild that drops them is throwing away bytes that were sitting +right there. + +That mattered to somebody. From the research, describing a report that broke: +*"it was a report with photos and a table of numbers"* — and later, on what the +recovery cost them, *"the photos I got off my phone again — the ones I still +had."* The words came back and the pictures did not, and re-sourcing the +pictures was most of the work. + +Two things this module refuses to do: + +* **Guess at a size.** Word needs an explicit extent in EMUs for an inline + image. The dimensions are read out of the image's own header — PNG, JPEG and + GIF all carry them in the first few bytes — and an image whose header cannot + be read gets a stated default rather than a fabricated measurement, with the + aspect ratio left alone rather than invented. +* **Pretend it knows where a picture went.** A drawing's position is recoverable + only when the relationship part survived to say which file the reference points + at. When it did not, the image is still returned — at the end, under its own + heading, with the report saying plainly that its original position could not be + recovered. An image silently placed in the wrong paragraph is worse than an + image obviously placed at the end. +""" + +from __future__ import annotations + +import re +import struct +from dataclasses import dataclass +from xml.etree import ElementTree as ET + +MEDIA_DIR = "word/media/" + +#: 914400 EMU to the inch, and images are authored at 96 DPI by convention. +EMU_PER_PX = 9525 +#: Six inches. Wider than the text column on A4 with the margins this rebuild +#: writes, so a large photograph is scaled down rather than running off the page. +MAX_WIDTH_EMU = 6 * 914400 +#: What an image gets when its header cannot be read: a square, stated as a +#: default in the code rather than presented as a measurement. +FALLBACK_PX = (480, 360) + +CONTENT_TYPES = { + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "gif": "image/gif", "bmp": "image/bmp", "tif": "image/tiff", + "tiff": "image/tiff", "emf": "image/x-emf", "wmf": "image/x-wmf", + "svg": "image/svg+xml", "webp": "image/webp", +} + +_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" + + +@dataclass(frozen=True) +class Image: + """One recovered picture.""" + + name: str # the member name inside the original, e.g. word/media/image1.png + data: bytes + width_px: int + height_px: int + measured: bool # False when the dimensions are the stated fallback + + @property + def ext(self) -> str: + return self.name.rsplit(".", 1)[-1].lower() if "." in self.name else "png" + + @property + def content_type(self) -> str: + return CONTENT_TYPES.get(self.ext, "application/octet-stream") + + @property + def extent(self) -> tuple[int, int]: + """Width and height in EMUs, scaled to fit the page, aspect preserved.""" + w = max(1, self.width_px) * EMU_PER_PX + h = max(1, self.height_px) * EMU_PER_PX + if w > MAX_WIDTH_EMU: + h = int(h * MAX_WIDTH_EMU / w) + w = MAX_WIDTH_EMU + return w, h + + +# -- reading a size out of the bytes ---------------------------------------- + +def image_size(data: bytes) -> tuple[int, int] | None: + """Width and height from the image's own header, or None. + + Deliberately hand-rolled over the three formats that account for almost + every image in a Word document. A dependency here would be a dependency in a + tool whose whole promise is that it runs offline, in milliseconds, for + somebody who is already having a bad day. + """ + if len(data) < 12: + return None + if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24: + w, h = struct.unpack(">II", data[16:24]) + return (w, h) if w and h else None + if data[:3] == b"GIF": + w, h = struct.unpack(" tuple[int, int] | None: + """Walk the segment chain to a start-of-frame marker. + + A JPEG's dimensions are not at a fixed offset — they sit in whichever SOF + segment the encoder wrote, after any number of application and quantisation + segments. Guessing an offset produces a plausible wrong number, which is the + kind of wrong this tool minds most. + """ + i, n = 2, len(data) + while i + 9 < n: + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + i += 2 + continue + if i + 4 > n: + return None + length = struct.unpack(">H", data[i + 2:i + 4])[0] + # SOF0-SOF15, excluding the four that are not frame headers. + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + if i + 9 > n: + return None + h, w = struct.unpack(">HH", data[i + 5:i + 9]) + return (w, h) if w and h else None + if length < 2: + return None + i += 2 + length + return None + + +def collect(members: dict[str, bytes]) -> dict[str, Image]: + """Every image that survived, keyed by its member name.""" + out: dict[str, Image] = {} + for name, data in members.items(): + if not name.startswith(MEDIA_DIR) or not data: + continue + size = image_size(data) + out[name] = Image( + name=name, data=data, + width_px=size[0] if size else FALLBACK_PX[0], + height_px=size[1] if size else FALLBACK_PX[1], + measured=size is not None, + ) + return out + + +# -- working out where they went -------------------------------------------- + +_REL_RE = re.compile( + rb'Id="(?P[^"]+)"[^>]*?Target="(?P[^"]+)"', re.S) + + +def relationship_targets(rels_xml: bytes | None) -> dict[str, str]: + """rId -> the member it points at, from `word/_rels/document.xml.rels`. + + Parsed properly when the part is well-formed and by regex when it is not, + because this part is as likely to be damaged as any other and half a map is + worth more here than none: an image whose relationship survived can go back + where it was, and the rest are still returned at the end. + """ + if not rels_xml: + return {} + targets: dict[str, str] = {} + try: + for rel in ET.fromstring(rels_xml): + rid, target = rel.get("Id"), rel.get("Target") + if rid and target: + targets[rid] = _normalise(target) + return targets + except ET.ParseError: + for m in _REL_RE.finditer(rels_xml): + targets[m.group("id").decode("utf-8", "replace")] = _normalise( + m.group("target").decode("utf-8", "replace")) + return targets + + +def _normalise(target: str) -> str: + """Relationship targets are relative to `word/`, and may say so the long way.""" + target = target.lstrip("/") + if target.startswith("word/"): + return target + while target.startswith("../"): + target = target[3:] + return f"word/{target}" + + +def embedded_ids(element: ET.Element) -> list[str]: + """The relationship ids of every image referenced inside this element.""" + ids: list[str] = [] + for blip in element.iter(f"{{{_A_NS}}}blip"): + rid = blip.get(f"{{{_R_NS}}}embed") or blip.get(f"{{{_R_NS}}}link") + if rid: + ids.append(rid) + # Older documents use VML rather than DrawingML, and a picture is a picture. + for imagedata in element.iter( + "{urn:schemas-microsoft-com:vml}imagedata"): + rid = imagedata.get(f"{{{_R_NS}}}id") + if rid: + ids.append(rid) + return ids + + +def drawing_xml(rel_id: str, image: Image, doc_pr_id: int, name: str) -> str: + """One inline image, as Word's own drawing markup. + + Written out longhand rather than templated from something shorter, because + every element in it is required: Word rejects an inline drawing missing an + extent, a docPr, a blipFill or a preset geometry, and rejecting it means the + owner's file does not open — which is the one outcome this build exists to + prevent. + """ + cx, cy = image.extent + return ( + "" + f'' + f'' + f'' + "" + '' + "" + f'' + f'' + "" + "" + f'' + '' + "" + "" + "" + ) diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/salvage.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/salvage.py new file mode 100644 index 00000000..abd8dd0a --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/salvage.py @@ -0,0 +1,254 @@ +"""Getting content out of a DOCX that will not open. + +A DOCX is a ZIP of XML parts. It breaks in a handful of recognisable ways, and +each one needs a different move: + + * The ZIP central directory is damaged or truncated -- the file "is not a + valid archive". Members can often still be found by scanning for local file + headers, which is what `salvage_members` does. + * `word/document.xml` is malformed -- an unclosed tag, a raw `&`, a stray + control character. A strict parser refuses the whole document over one bad + byte, so we repair the common cases and then fall back to pulling text out + of the runs directly. + * A part is simply missing. `[Content_Types].xml` and the relationship parts + are rebuildable from scratch; the document body is not. + +Nothing here promises a full repair. Every function reports what it could not +do, because the owner finding out later is the failure mode this tool exists to +prevent. +""" + +from __future__ import annotations + +import io +import re +import zipfile +from dataclasses import dataclass, field + +W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + +# A local file header. Scanning for these recovers members when the central +# directory -- which is at the END of the file, and so is what a truncated +# download loses first -- is gone. +_LOCAL_HEADER = b"PK\x03\x04" + +# Standard OOXML plumbing. These are regenerated on write, so damage to them +# costs the owner nothing and must not be reported as lost content. +REBUILDABLE = { + "[Content_Types].xml", + "_rels/.rels", + "word/_rels/document.xml.rels", + "word/styles.xml", +} + +_ILLEGAL_XML = re.compile( + rb"[\x00-\x08\x0b\x0c\x0e-\x1f]" # control characters XML 1.0 forbids +) +# A bare ampersand: one not already starting a valid entity. +_BARE_AMP = re.compile(rb"&(?!#[0-9]+;|#x[0-9a-fA-F]+;|[a-zA-Z][a-zA-Z0-9]*;)") + + +def plural(n: int, noun: str, plural_form: str | None = None) -> str: + """Consumer copy, not log output. "1 table" and "2 tables", never "1 table(s)".""" + return f"{n} {noun}" if n == 1 else f"{n} {plural_form or noun + 's'}" + + +@dataclass +class Salvage: + members: dict[str, bytes] = field(default_factory=dict) + notes: list[str] = field(default_factory=list) + unrecovered: list[str] = field(default_factory=list) + + def note(self, msg: str) -> None: + self.notes.append(msg) + + def lost(self, msg: str) -> None: + self.unrecovered.append(msg) + + +def salvage_members(data: bytes) -> Salvage: + """Get whatever parts can be read out of the container.""" + out = Salvage() + + # The happy path first: a readable archive. + try: + with zipfile.ZipFile(io.BytesIO(data)) as z: + bad = z.testzip() + for name in z.namelist(): + try: + out.members[name] = z.read(name) + except Exception: + out.lost(f"one section of the file ({name}) is present but its data is unreadable") + if bad: + out.note("part of the file failed its integrity check; read what was readable") + elif out.members and not out.unrecovered: + out.note("the container opened cleanly") + return out + except (zipfile.BadZipFile, OSError): + out.note( + "the file's internal index was damaged — this is what Word reports as " + "a corrupt document — so the content was read directly from inside it" + ) + + # Damaged container. Re-opening a slice does not help -- a truncated ZIP has + # no central directory anywhere in it -- so read the local file headers by + # hand and inflate each member's stream directly. + found, partial = _recover_by_local_headers(data, out) + if found: + out.note(f"found and read {plural(found, 'section')} of the file this way") + else: + out.lost("no readable parts could be found inside the file") + return out + + +def _recover_by_local_headers(data: bytes, out: "Salvage") -> tuple[int, int]: + """Walk the local file headers and inflate what each one points at. + + The central directory sits at the END of a ZIP, so a truncated download + loses it first while the members themselves are still largely intact. Each + local header carries its own name, method and sizes, which is enough. + """ + import struct + import zlib + + found = partial = 0 + for match in re.finditer(re.escape(_LOCAL_HEADER), data): + start = match.start() + header = data[start:start + 30] + if len(header) < 30: + continue + try: + (_, _, flags, method, _, _, _, comp_size, uncomp_size, + name_len, extra_len) = struct.unpack(" tuple[bytes, list[str]]: + """Fix the XML faults a strict parser refuses to look past.""" + notes: list[str] = [] + fixed = raw + + if fixed.startswith(b"\xef\xbb\xbf"): + fixed = fixed[3:] + notes.append("removed a byte-order mark that was placed before the XML declaration") + + cleaned, n = _ILLEGAL_XML.subn(b"", fixed) + if n: + fixed = cleaned + notes.append(f"removed {plural(n, 'invalid character')} that were breaking the document") + + cleaned, n = _BARE_AMP.subn(b"&", fixed) + if n: + fixed = cleaned + notes.append(f"repaired {plural(n, 'stray ampersand')}") + + closed, added = _close_open_tags(fixed) + if added: + fixed = closed + notes.append( + f"closed {plural(len(added), 'element')} that the damage had left open") + + return fixed, notes + + +def _close_open_tags(raw: bytes) -> tuple[bytes, list[str]]: + """Close tags a truncated file left hanging. + + Deliberately simple: it only appends closing tags for elements still open at + the end. It does not try to reorder or invent content -- guessing at + structure is how a "repair" quietly changes what a document says. + """ + try: + text = raw.decode("utf-8", errors="replace") + except Exception: + return raw, [] + + stack: list[str] = [] + for m in re.finditer(r"<\s*(/?)([A-Za-z_][\w:.\-]*)([^>]*?)(/?)\s*>", text): + closing, name, attrs, self_closing = m.groups() + if name.startswith("?") or name.startswith("!"): + continue + if self_closing == "/": + continue + if closing == "/": + if stack and stack[-1] == name: + stack.pop() + elif name in stack: + while stack and stack.pop() != name: + pass + else: + stack.append(name) + + if not stack: + return raw, [] + tail = "".join(f"" for n in reversed(stack)) + return raw + tail.encode("utf-8"), list(reversed(stack)) + + +def text_runs(document_xml: bytes) -> list[str]: + """Last resort: pull the text out of `` runs with a regex. + + Used when the XML is too damaged to parse even after repair. It loses all + structure, which is exactly why the report says so rather than presenting + the result as a recovered document. + """ + return [ + _unescape(m.group(1).decode("utf-8", errors="replace")) + for m in re.finditer(rb"]*>(.*?)", document_xml, re.S) + if m.group(1).strip() + ] + + +def _unescape(s: str) -> str: + return (s.replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'").replace("&", "&")) diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/styled_export.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/styled_export.py new file mode 100644 index 00000000..dd8d1ba5 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/styled_export.py @@ -0,0 +1,257 @@ +"""Take the recovered structure through SuperDocs to get a properly styled file. + +The local rebuild always runs first and always produces a valid file. This is +the optional second pass: hand the recovered structure to SuperDocs as HTML, +have it normalise the document, and export a styled DOCX. + +Why HTML and not the rebuilt .docx bytes: the documentation is explicit that +SuperDocs takes documents and HTML and that there is no endpoint for raw Word +XML. Sending clean HTML built from structure we have already parsed is both +supported and lossless in the directions that matter -- headings stay headings, +tables stay tables. + +The four calls, in order, are exactly the required contract: + upload -> edit instruction -> approve -> export +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .docx import Block, blocks_to_html +from .superdocs_client import QuotaExhausted, SuperDocsClient + +INSTRUCTION = ( + "Formatting only. This document was recovered from a damaged file and its " + "text is the only record of what its owner wrote. Apply consistent heading " + "styles, table formatting and paragraph spacing. Do NOT add, remove, " + "expand, summarise, complete or reword any text. Do not add sections, " + "headings, rows, totals, placeholders, disclaimers, signature blocks, " + "headers, footers or dates. Do not fill gaps. If a passage looks " + "incomplete, leave it exactly as it is. The word-for-word text of the " + "output must be identical to the input." +) + +#: How many words of difference count as "it only restyled it". Zero: a +#: formatting pass has no reason to change a single word, and the one thing +#: this product cannot do is hand somebody a recovered document containing +#: sentences they never wrote. +ALLOWED_WORD_DRIFT = 0 + + +def _words(text: str) -> list[str]: + """Text reduced to what a reader would call the words, so that a formatting + pass -- which may re-wrap, re-space or re-escape -- reads as no change.""" + import html as _html + import re + + plain = _html.unescape(re.sub(r"<[^>]+>", " ", text)) + return re.findall(r"[a-z0-9]+", plain.lower()) + + +def docx_text(blob: bytes) -> str: + """The text of a .docx, run by run. Deliberately not a full parse: this is + asked only "what does it say".""" + import io + import re + import zipfile + + try: + with zipfile.ZipFile(io.BytesIO(blob)) as z: + body = z.read("word/document.xml").decode("utf-8", "replace") + except Exception: + return "" + return " ".join(re.findall(r"]*>([^<]*)", body)) + + +def content_drift(sent_html: str, got_docx: bytes) -> tuple[int, int]: + """(words added, words removed) between what we sent and what came back. + + A multiset rather than a sequence: reordering a table's cells is not this + guard's business, and inventing a paragraph is. + """ + from collections import Counter + + before = Counter(_words(sent_html)) + after = Counter(_words(docx_text(got_docx))) + added = sum((after - before).values()) + removed = sum((before - after).values()) + return added, removed + + +@dataclass +class Allowance: + """What the platform says is left, before anything is spent. + + `known` is false when the balance could not be read. That is not the same + as zero and is never reported as one: a personal key is not an agent key, + and `/v1/agents/whoami` answers only the latter. An unreadable balance lets + the work proceed and says the number is unknown — refusing on a number + nobody read would be its own kind of bluff. + """ + + known: bool = False + remaining: int = 0 + tier: str = "" + + +def allowance(client: SuperDocsClient) -> Allowance: + """Trap 3, asked before the loop rather than discovered inside it. + + `GET /v1/agents/whoami` carries `quota: {tier, monthly_limit, used, + remaining}` — the one balance read available in advance of doing work. + """ + try: + r = client.whoami() + except Exception: + return Allowance() + body = r.body if isinstance(r.body, dict) else {} + quota = body.get("quota") or {} + if "remaining" not in quota: + return Allowance() + try: + return Allowance(known=True, remaining=int(quota["remaining"]), + tier=str(quota.get("tier", ""))) + except (TypeError, ValueError): + return Allowance() + + +@dataclass +class StyledResult: + ok: bool = False + output: bytes = b"" + #: Set when a styled file came back but was thrown away because its text no + #: longer matched. Kept apart from a transport failure: one is nobody's + #: fault, the other is a claim we refused to pass on. + rejected_for_content: bool = False + warnings: list = field(default_factory=list) + notes: list[str] = field(default_factory=list) + ops_charged: int = 0 + ops_confirmed: bool = False # the async endpoints return no usage block + allowance_known: bool = False + allowance_remaining: int = 0 + + +def styled_export(client: SuperDocsClient, session_id: str, blocks: list[Block], + filename: str = "recovered.html", + on_progress=None) -> StyledResult: + """Best effort, and it never costs the caller their local result. + + Every failure path returns ok=False with a reason. The engine's own rebuild + has already succeeded by the time this runs, so a failure here degrades to + "you get the plain rebuild" rather than to "you get nothing". + """ + r = StyledResult() + + def say(msg: str) -> None: + r.notes.append(msg) + if on_progress: + on_progress("superdocs", msg) + + html = blocks_to_html(blocks) + + # 0 -- the allowance, before a single billable call. Starting a styling pass + # that cannot finish would leave someone watching a progress line for work + # that was refused at the far end. + left = allowance(client) + r.allowance_known = left.known + r.allowance_remaining = left.remaining + if left.known and left.remaining < 1: + say("There is no styling allowance left this month, so nothing was sent " + "and nothing was spent. The rebuilt file is unchanged and still yours.") + return r + + try: + # 1 -- upload. Free. + say("Sending the recovered content to SuperDocs for styling…") + client.upload(session_id, filename, html.encode("utf-8")) + + # 2 -- edit instruction, with a human gate on every proposed change. + started = client.edit(session_id, INSTRUCTION) + r.ops_charged += int(started.usage.get("ops_charged", 0)) + job_id = started.body.get("job_id") + if not job_id: + say("SuperDocs did not start a job; keeping the plain rebuild.") + return r + + # 3 -- wait. A long silence is still processing. + say("Waiting for SuperDocs to finish — large documents can take minutes.") + job = client.poll_job(job_id) + r.ops_charged += int(job.usage.get("ops_charged", 0)) + + if job.body.get("status") == "awaiting_approval": + from .superdocs_client import pending_changes + + changes = pending_changes(job.body) + if changes: + # Formatting-only changes on a document we just rebuilt: approve + # them. The instruction forbids content edits, so a change that + # rewrote text would be a bug on their side, not a judgement + # call on ours -- and the export below is what gets checked. + approved = client.approve( + session_id, job_id, + [{"change_id": c.get("change_id"), "approved": True} for c in changes], + ) + r.ops_charged += int(approved.usage.get("ops_charged", 0)) + say(f"Approved {len(changes)} formatting change(s).") + + # Approval is asynchronous: the call returns ok, then the job + # resumes and applies the change. Exporting before it settles + # returns the pre-edit document with a 200 and no warning. + settled = client.poll_job(job_id, deadline_s=300, interval_s=2) + r.ops_charged += int(settled.usage.get("ops_charged", 0)) + if settled.body.get("status") != "completed": + say("SuperDocs did not finish applying the changes; " + "keeping the plain rebuild.") + return r + + # 4 -- export. Free. + exported = client.export(session_id, "docx") + r.warnings = client.export_warnings(exported) + blob = exported.body.get("raw") if isinstance(exported.body, dict) else None + if not blob: + say("SuperDocs returned no file; keeping the plain rebuild.") + return r + candidate = blob if isinstance(blob, bytes) else bytes(blob) + + # The guard. The instruction forbids content changes; this checks + # rather than trusts, because a document-editing model handed a sparse + # recovered file will fill it out -- observed live on 2026-08-20, where + # a four-line report came back with invented paragraphs, a subtotal + # row, a disclaimer and a signature block. Handing that to somebody who + # came here to get their own words back is the worst output this + # product could produce: it opens cleanly, it looks better than the + # plain rebuild, and it is partly fiction. + added, removed = content_drift(html, candidate) + if added + removed > ALLOWED_WORD_DRIFT: + r.rejected_for_content = True + say("The styled version came back with the wording changed, so it " + "was thrown away rather than handed over. Your document should " + "say what you wrote. The rebuilt file is unchanged and still " + "yours.") + return r + + r.output = candidate + r.ok = True + if not r.ops_charged: + # The async endpoints return no usage block (see BUG-017), so a + # zero here means "not reported", not "free". Saying "0 operations" + # about a billable request would be the same bluff in a new place. + r.ops_charged = 1 + r.ops_confirmed = False + else: + r.ops_confirmed = True + say("SuperDocs returned a styled file.") + if r.warnings: + say(f"The export completed with {len(r.warnings)} non-fatal warning(s).") + except QuotaExhausted: + say("The SuperDocs allowance is exhausted; keeping the plain rebuild.") + except TimeoutError: + # Not a failure on their side -- the job may still be running. But we + # cannot export until it settles, so the local rebuild is what ships. + say("SuperDocs did not finish applying the changes in time; " + "keeping the plain rebuild.") + except Exception: + # Deliberately no exception class name: this string reaches a user. + say("Styling through SuperDocs did not work; keeping the plain rebuild.") + return r diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/superdocs_client.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/superdocs_client.py new file mode 100644 index 00000000..8bd486ff --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/superdocs_client.py @@ -0,0 +1,415 @@ +"""The four-call contract: upload, edit instruction, approve, export. + +SHARED, AND VENDORED. This file is a byte-for-byte copy of +`quota-aware-agent/quota_aware_agent/client.py` below this docstring, so each +build stands alone in the builds repository. + +Vendoring has a cost and this project paid it: the multipart fix for BUG-015 +landed in the original and not here, so Build B's styling path still sent an +empty body and got a 422 long after Build A was working. `test_the_vendored +_client_has_not_drifted` now fails the build if the two copies diverge. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Protocol + +BASE = "https://api.superdocs.app" + +# Terminal and non-terminal job states, from the docs' job lifecycle. +_TERMINAL = {"completed", "failed", "cancelled"} +_NEEDS_HUMAN = "awaiting_approval" + + +class Transport(Protocol): + def request(self, method: str, path: str, **kw: Any) -> "Response": ... + + +@dataclass +class Response: + status: int + body: dict + headers: dict = field(default_factory=dict) + + @property + def usage(self) -> dict: + """The usage block rides on every chat response. It is the only way to + read the balance from an API-key context -- the account usage endpoints + reject `sk_` keys with a 401.""" + return self.body.get("usage", {}) or {} + + +class SuperDocsError(RuntimeError): + def __init__(self, status: int, body: Any) -> None: + super().__init__(f"SuperDocs returned {status}: {body}") + self.status = status + self.body = body + + +class QuotaExhausted(SuperDocsError): + """Raised only when the platform says so. Never inferred from our own count.""" + + +class TransportFailure(RuntimeError): + """The call did not produce a response, and we have to say which kind. + + The distinction is the whole point. A connection that was refused means the + request never reached SuperDocs and cannot have been billed, so a rerun may + safely repeat it. A read that timed out means the request very possibly did + reach SuperDocs, was charged, and applied an edit -- we simply never heard + the answer. Collapsing the two into "it failed" is how a retry pays twice. + """ + + def __init__(self, message: str, *, never_sent: bool) -> None: + super().__init__(message) + self.never_sent = never_sent + + +def provably_never_sent(exc: BaseException) -> bool: + """True only when the request cannot have been billed. + + Deliberately conservative: the default answer is "we do not know", because + the cost of wrongly believing a call was billed is one step reported to a + person, and the cost of wrongly believing it was not is the user paying + twice and possibly getting the same edit applied twice. + """ + import socket + + if isinstance(exc, TransportFailure): + return exc.never_sent + if isinstance(exc, QuotaExhausted): + # The request that carried this signal completed; it was answered. + return False + if isinstance(exc, SuperDocsError): + # A 4xx was rejected before any work happened, so it was not billed. + # A 5xx may have come from a gateway that had already passed the + # request on, so it proves nothing. + return exc.status < 500 + reason = getattr(exc, "reason", exc) + return isinstance(reason, (ConnectionRefusedError, socket.gaierror)) + + +def _encode_multipart(files: dict, fields: dict) -> tuple[bytes, str]: + """Build a multipart/form-data body from {name: (filename, bytes)} plus + plain fields. Returns (body, content_type). + + A fixed boundary would collide with content that happens to contain it, so + it is derived from the payload -- deterministic for a given body, which + keeps requests reproducible, and vanishingly unlikely to appear inside it. + """ + import hashlib + + digest = hashlib.sha256() + for name, (filename, content) in sorted(files.items()): + digest.update(name.encode()) + digest.update(str(filename).encode()) + digest.update(content if isinstance(content, bytes) else str(content).encode()) + boundary = "----formdata" + digest.hexdigest()[:24] + + out = bytearray() + for name, value in fields.items(): + out += f"--{boundary}\r\n".encode() + out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + out += str(value).encode("utf-8") + b"\r\n" + for name, (filename, content) in files.items(): + if isinstance(content, str): + content = content.encode("utf-8") + out += f"--{boundary}\r\n".encode() + out += (f'Content-Disposition: form-data; name="{name}"; ' + f'filename="{filename}"\r\n').encode() + out += f"Content-Type: {_guess_type(filename)}\r\n\r\n".encode() + out += content + b"\r\n" + out += f"--{boundary}--\r\n".encode() + return bytes(out), f"multipart/form-data; boundary={boundary}" + + +def _guess_type(filename: str) -> str: + import mimetypes + + return mimetypes.guess_type(str(filename))[0] or "application/octet-stream" + + +class HttpTransport: + """Real transport. Imported lazily so the package needs no HTTP library + installed to run its tests.""" + + def __init__(self, api_key: str, base: str = BASE, timeout: float = 300.0) -> None: + self._key = api_key + self._base = base + # ~300s is the platform gateway timeout for synchronous requests. + self._timeout = timeout + + def request(self, method: str, path: str, **kw: Any) -> Response: + import urllib.error + import urllib.request + + url = self._base + path + headers = {"Authorization": f"Bearer {self._key}"} + data = None + if "files" in kw: + # Upload is multipart/form-data, not JSON. Encoded here rather than + # with a library because this package has no dependencies -- and + # because getting it wrong is invisible: the request still sends, + # and the API answers 422 for a field it never received. + data, content_type = _encode_multipart(kw["files"], kw.get("data", {})) + headers["Content-Type"] = content_type + elif "json" in kw: + data = json.dumps(kw["json"]).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as r: + raw = r.read() + body = json.loads(raw) if raw and r.headers.get_content_type() == "application/json" else {"raw": raw} + return Response(r.status, body, dict(r.headers)) + except urllib.error.HTTPError as e: + raw = e.read() + try: + body = json.loads(raw) + except Exception: + body = {"raw": raw.decode(errors="replace")} + return Response(e.code, body, dict(e.headers or {})) + except urllib.error.URLError as e: + # Name the cause and the fix, and -- more importantly -- say whether + # the request could have been billed, so the ledger can record the + # truth rather than the convenient answer. + never_sent = provably_never_sent(e) + raise TransportFailure( + f"could not reach {self._base} ({e.reason}). " + + ("The connection was refused or the host did not resolve, so " + "the request never reached SuperDocs and was not billed — " + "check network access to api.superdocs.app and retry." + if never_sent else + "It is not known whether the request arrived, so it must not " + "be assumed unbilled — rerun and read what the operation " + "ledger reports about it."), + never_sent=never_sent, + ) from e + except TimeoutError as e: + # A read timeout is the ambiguous case by definition: the request + # went out and the answer never came back. + raise TransportFailure( + f"no response from {self._base} within {self._timeout:.0f}s. " + "SuperDocs may still be processing this — large documents and " + "the deepest model settings take minutes — so the request may " + "well have been accepted and billed. It is recorded as started " + "and unconfirmed rather than retried.", + never_sent=False, + ) from e + + +#: What the upload endpoint parses each extension as. Verified against the live +#: API 2026-08-20: the **filename decides the parser**, not the bytes. HTML sent +#: as `report.docx` is answered `400 Invalid DOCX file: File is not a zip file`, +#: and the same bytes as `report.html` are accepted. `.txt` is accepted too and +#: parses the markup as literal text, which is worse than an error because it +#: succeeds. +_ZIP_EXTENSIONS = {".docx", ".xlsx", ".pptx", ".odt"} +_PDF_EXTENSIONS = {".pdf"} +_TEXT_EXTENSIONS = {".html", ".htm", ".txt", ".md", ".markdown", ".rtf"} + + +def check_upload_name(filename: str, content: bytes) -> None: + """Refuse a filename whose extension disagrees with the bytes. + + This is a deliberate hardcoded defence sitting in front of the intelligent + path, not a guess about what the caller meant. The live API decides how to + parse an upload from the extension alone, so `agent.run(..., "contract.docx", + html_bytes)` reads perfectly and fails at the platform with a message about + zip files, which names neither the cause nor the fix. Worse, the mismatch + that does NOT error — HTML uploaded as `.txt` — succeeds and quietly parses + the markup as literal text, and nobody finds out until the export. + + So it is checked here, before anything is sent, and the error says which + two things disagreed and both ways to make them agree. + """ + import os + + ext = os.path.splitext(str(filename))[1].lower() + if not ext: + raise ValueError( + f"'{filename}' has no file extension. SuperDocs chooses how to parse " + "an upload from the extension, so give one — '.html' for HTML, " + "'.docx' for a Word file, '.pdf' for a PDF.") + + looks_like_zip = content[:4] == b"PK\x03\x04" + looks_like_pdf = content[:4] == b"%PDF" + + if ext in _ZIP_EXTENSIONS and not looks_like_zip: + raise ValueError( + f"'{filename}' is named as a Word-family file but the bytes are not " + "a zip archive, and SuperDocs parses uploads by extension — it would " + "answer '400 Invalid DOCX file: File is not a zip file'. Either send " + "the real .docx bytes, or rename this to '.html' if it is HTML.") + if ext in _PDF_EXTENSIONS and not looks_like_pdf: + raise ValueError( + f"'{filename}' is named as a PDF but the bytes do not begin with " + "'%PDF'. Send the real PDF bytes, or rename it to match what it is.") + if ext in _TEXT_EXTENSIONS and (looks_like_zip or looks_like_pdf): + raise ValueError( + f"'{filename}' is named as text but the bytes are a " + f"{'zip archive (a .docx, most likely)' if looks_like_zip else 'PDF'}. " + "This would be accepted and parsed as literal text rather than as a " + "document — rename it to match its contents.") + + +def pending_changes(job_body: dict) -> list[dict]: + """Read the proposed changes off a job, whatever shape they arrive in. + + Two shapes exist and both are real: + * `GET /v1/jobs/{id}` returns `metadata.pending_changes` as a plain LIST + of change dicts. Verified against the live API 2026-08-19. + * The SSE `proposed_change_batch` event delivers an envelope whose + `content` is a JSON-encoded STRING needing a second parse. + + This lives in the client because both builds need it, and the copy that had + it written separately handled only the envelope -- so it crashed on the + shape the polling path actually returns. + """ + meta = job_body.get("metadata") or {} + pending = meta.get("pending_changes") + if pending is None: + for event in meta.get("intermediate_responses", []) or []: + if event.get("type") == "proposed_change_batch": + return parse_proposed_changes(event) + return [] + if isinstance(pending, list): + return list(pending) + if isinstance(pending, str): + return parse_proposed_changes({"content": pending}) + return parse_proposed_changes(pending) + + +def parse_proposed_changes(envelope: dict) -> list[dict]: + """Trap 1. The batch arrives as a JSON string inside `content`. + + A single-change turn still arrives as a one-element `changes[]`, so this + always returns a list and never special-cases the singular form. + """ + content = envelope.get("content") + if content is None: + return list(envelope.get("changes", [])) + batch = json.loads(content) if isinstance(content, str) else content + return list(batch.get("changes", [])) + + +class SuperDocsClient: + def __init__(self, transport: Transport, sleep: Callable[[float], None] = time.sleep) -> None: + self._t = transport + self._sleep = sleep + #: Set once the platform has said the allowance is exhausted, including + #: when it said so on a free call that was allowed to complete anyway. + self.quota_exhausted = False + + def _check(self, r: Response, *, billable: bool = True) -> Response: + """Raise on an error, and on the platform's own exhaustion signal. + + `billable=False` marks the free calls -- whoami and export. An exhausted + allowance must not stop those: exports and downloads never cost + operations, and the reserve exists precisely to promise that the work + already done can still be exported. Raising here would break that + promise at the exact moment it matters, turning "you always end up with + a file" into "you end up with a session and an exception". The signal is + still recorded, so the caller stops spending; it just does not block a + call that costs nothing. + """ + if r.status >= 400: + raise SuperDocsError(r.status, r.body) + if r.usage.get("quota_exhausted"): + # The current request still completed; further billable ones will not. + self.quota_exhausted = True + if billable: + raise QuotaExhausted(r.status, r.body) + return r + + # --- call 0: the one authoritative balance read available to an agent key. + def whoami(self) -> Response: + # Free, and it is the call that tells you the allowance is gone. Being + # refused by the exhaustion it exists to report would be absurd. + return self._check(self._t.request("GET", "/v1/agents/whoami"), + billable=False) + + # --- call 1 of the contract: upload. + def upload(self, session_id: str, filename: str, content: bytes) -> Response: + check_upload_name(filename, content) + return self._check( + self._t.request( + "POST", "/v1/documents/upload", + files={"file": (filename, content)}, data={"session_id": session_id}, + ) + ) + + # --- call 2: the edit instruction. + def edit(self, session_id: str, message: str, approval_mode: str = "ask_every_time") -> Response: + return self._check( + self._t.request( + "POST", "/v1/chat/async", + json={"session_id": session_id, "message": message, "approval_mode": approval_mode}, + ) + ) + + def job(self, job_id: str) -> Response: + return self._check(self._t.request("GET", f"/v1/jobs/{job_id}")) + + def poll_job(self, job_id: str, deadline_s: float = 600.0, interval_s: float = 2.0, + on_wait: Callable[[float, str], None] | None = None) -> Response: + """Trap 2. Silence is still processing. + + Returns as soon as the job is terminal *or* is waiting on a human. Gives + up only at an explicit deadline, and says how long it waited -- a slow + job is never reported as a crash. + """ + waited = 0.0 + while True: + r = self.job(job_id) + status = r.body.get("status", "") + if status in _TERMINAL or status == _NEEDS_HUMAN: + return r + if waited >= deadline_s: + raise TimeoutError( + f"job {job_id} was still '{status}' after {waited:.0f}s. " + "That is a deadline this client imposed, not a platform failure -- " + "the job may still be running." + ) + if on_wait: + on_wait(waited, status) + self._sleep(interval_s) + waited += interval_s + + # --- call 3: approve, item by item. + def approve(self, session_id: str, job_id: str, decisions: list[dict]) -> Response: + """`decisions` is a list of {change_id, approved, feedback?}. Sent as a + batch so a mixed approve/deny turn is one request, not one per change.""" + return self._check( + self._t.request( + "POST", f"/v1/chat/{session_id}/approve", + json={"job_id": job_id, "approved": True, "changes": decisions}, + ) + ) + + # --- call 4: export. Free, per the docs, and so never priced. + def export(self, session_id: str, fmt: str = "docx") -> Response: + """Free per the docs, so an exhausted allowance never blocks it.""" + return self._check( + self._t.request("POST", "/v1/documents/export", + json={"session_id": session_id, "format": fmt}), + billable=False, + ) + + @staticmethod + def export_warnings(r: Response) -> list: + """Exports can succeed with non-fatal issues, carried base64-encoded in + `X-Export-Warnings`. Surfaced rather than swallowed -- a dropped field + code is exactly the kind of thing a user should be told about.""" + import base64 + + header = r.headers.get("X-Export-Warnings") + if not header: + return [] + try: + return json.loads(base64.b64decode(header)) + except Exception: + return [] diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/web.py b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/web.py new file mode 100644 index 00000000..f3090496 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/docrepair/web.py @@ -0,0 +1,310 @@ +"""The web page. This is the product; the CLI is the same engine with a +different front door. + +Progress is streamed, not simulated. The repair runs on a worker thread and +pushes each stage onto a queue as it happens, and the response is newline- +delimited JSON the browser reads incrementally. A page that fakes a progress +bar after the work is already done is lying to the person watching it. + +Two things are handed over, in this order and never the other way round: + + 1. the local rebuild, which needs no key, no network and no account, and + 2. optionally, the same content styled through SuperDocs. + +The second is a *second* file, offered after the first is already downloadable. +It was a `--via-superdocs` flag on the CLI and nothing else, which meant the +card's own surface -- API and export -- was reachable only by somebody who had +read the source and set an environment variable. A capability the product's +user cannot reach is not the same failure as an absent one, but on a card +banded *API + export* it is close enough to matter. +""" + +from __future__ import annotations + +import json +import os +import queue +import threading +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from pathlib import Path + +from fastapi import FastAPI, HTTPException, UploadFile +from fastapi.responses import HTMLResponse, Response, StreamingResponse + +from .docx import Block +from .engine import Repair, repair + +app = FastAPI(title="Repair my broken Word doc") + + +@dataclass +class Held: + """A finished file, waiting to be collected. + + The blocks ride along so the styling pass does not have to re-open and + re-parse the file this process just wrote. They are the recovered structure + itself; sending it as HTML is what the styling pass does with them. + """ + + filename: str + blob: bytes + at: float + blocks: list[Block] = field(default_factory=list) + + +# Repaired files, held in memory briefly. Nothing is written to disk: the file +# someone uploads is their damaged document, and keeping it is not ours to do. +# +# Downloads are NOT one-shot. Clicking the button twice is the most ordinary +# thing a person does, and the first version dropped the file on the first +# click -- so the second attempt 404'd and their only copy was gone. Entries +# now survive for a short window and are evicted oldest-first. +_READY: "OrderedDict[str, Held]" = OrderedDict() +MAX_BYTES = 20 * 1024 * 1024 +KEEP_SECONDS = 30 * 60 +KEEP_MOST_RECENT = 32 + + +def _evict(now: float) -> None: + for token, held in list(_READY.items()): + if now - held.at > KEEP_SECONDS: + del _READY[token] + while len(_READY) > KEEP_MOST_RECENT: + _READY.popitem(last=False) + + +def _hold(filename: str, blob: bytes, blocks: list[Block] | None = None) -> str: + import time as _time + + _evict(_time.monotonic()) + token = uuid.uuid4().hex + _READY[token] = Held(filename, blob, _time.monotonic(), blocks or []) + return token + + +def _ndjson(work, result: dict) -> StreamingResponse: + """Run `work(say)` on a thread and stream what it says, then the result. + + Both endpoints answer the same way, because both are a person watching + something slow happen to their document. Factored out rather than written + twice so a fix to one is a fix to both. + """ + q: queue.Queue = queue.Queue() + + def run() -> None: + try: + work(lambda stage, message: q.put({"stage": stage, "message": message})) + finally: + q.put(None) + + threading.Thread(target=run, daemon=True).start() + + def stream(): + while True: + item = q.get() + if item is None: + break + yield json.dumps(item) + "\n" + yield json.dumps({"done": True, **result.get("payload", {})}) + "\n" + + return StreamingResponse(stream(), media_type="application/x-ndjson") + + +def _styling_key() -> str | None: + key = os.environ.get("SUPERDOCS_API_KEY") + return key.strip() or None if key else None + + +@app.get("/", response_class=HTMLResponse) +def index() -> str: + return (Path(__file__).parent.parent / "static" / "index.html").read_text() + + +@app.get("/api/capabilities") +def capabilities() -> dict: + """What this particular copy of the page can actually do. + + Asked by the page before it offers anything, so the styling step is either + genuinely available or plainly explained -- never a button that fails when + somebody presses it. + """ + on = _styling_key() is not None + return { + "styling": on, + "note": ( + "Styling is available on this page." + if on + else "Styling is switched off on this copy of the page, so the " + "rebuilt file is the plain one. Nothing else is affected." + ), + } + + +@app.post("/api/repair") +async def api_repair(file: UploadFile): + data = await file.read() + if not data: + raise HTTPException(400, "That file is empty.") + if len(data) > MAX_BYTES: + raise HTTPException(413, "That file is larger than 20 MB.") + + result: dict = {} + + def work(say) -> None: + try: + r = repair(data, file.filename or "document.docx", + on_progress=lambda stage, msg: say(stage, msg)) + token = _hold(r.filename, r.output, _blocks_of(r)) if r.ok else "" + result["payload"] = r.as_payload( + f"/api/download/{token}" if r.ok else None, + f"/api/style/{token}" if r.ok else None, + ) + except Exception as e: # never leak a stack trace to a consumer + # Built from a Repair rather than written out by hand, so this + # cannot become a second, drifting version of the wire shape -- + # and so the honesty guards, which run over what reaches the page, + # cover this path too. The exception type never travels: it is + # engine vocabulary, and BUG-020 was a class name reaching a + # worried person. + failed = Repair(filename="") + failed.lost.append( + "this file could not be read at all, and nothing was changed") + failed.stages.append(("open", f"stopped: {type(e).__name__}")) + result["payload"] = failed.as_payload() + result["payload"]["summary"] = ( + "Something went wrong while reading this file, so it was left " + "alone. Nothing was changed and nothing was saved.") + + return _ndjson(work, result) + + +def _blocks_of(r: Repair) -> list[Block]: + """The recovered structure, read back out of the file just written. + + Read from the output rather than kept from the run because the output is + what the person downloads: styling a structure that differs from the file + in their hands would make the two deliverables disagree. + """ + import io + import zipfile + + from .docx import read_blocks + + try: + with zipfile.ZipFile(io.BytesIO(r.output)) as z: + return read_blocks(z.read("word/document.xml")) + except Exception: + return [] + + +@app.post("/api/style/{token}") +def api_style(token: str): + """The optional second pass: the four-call SuperDocs contract, on request. + + It is never automatic. Someone whose document just broke should not have it + sent to a third-party service because a page decided that for them, and the + plain file is already downloadable before this button exists. + """ + import time as _time + + _evict(_time.monotonic()) + held = _READY.get(token) + if held is None: + raise HTTPException( + 404, + "That repaired file is no longer being held. Repair the document again " + "to get a fresh copy — your original was never changed.", + ) + if not held.blocks: + raise HTTPException( + 409, + "There is no readable structure in this result to style. The rebuilt " + "file above is unchanged and still yours.", + ) + key = _styling_key() + if key is None: + raise HTTPException( + 409, + "Styling is switched off on this copy of the page. The rebuilt file " + "above is unchanged and still yours.", + ) + + result: dict = {} + + def work(say) -> None: + from .styled_export import styled_export + from .superdocs_client import HttpTransport, SuperDocsClient + + from .styled_export import StyledResult + + try: + client = SuperDocsClient(HttpTransport(key)) + res = styled_export( + client, f"salvage-{token[:12]}", held.blocks, + on_progress=lambda stage, msg: say(stage, msg), + ) + except Exception: + # `styled_export` already degrades on everything it can see. This + # is the belt on top of it: whatever went wrong here, the person + # keeps the file they already had and is told so in their own + # words. No exception class travels -- that was BUG-020. + res = StyledResult() + res.notes.append("Styling did not work. The rebuilt file above is " + "unchanged and still yours.") + styled_token = "" + if res.ok: + styled_token = _hold(_styled_name(held.filename), res.output) + result["payload"] = { + "ok": res.ok, + "notes": res.notes, + # Two different "no": one where nothing came back, one where + # something came back and was refused. The page says them + # differently because they mean different things to a person. + "rejected_for_content": res.rejected_for_content, + "filename": _styled_name(held.filename), + "download": f"/api/download/{styled_token}" if res.ok else None, + "ops_charged": res.ops_charged, + "ops_confirmed": res.ops_confirmed, + "allowance_known": res.allowance_known, + "allowance_remaining": res.allowance_remaining, + "warnings": len(res.warnings), + } + + return _ndjson(work, result) + + +def _styled_name(filename: str) -> str: + stem = filename[:-5] if filename.lower().endswith(".docx") else filename + return f"{stem}-styled.docx" + + +@app.get("/api/download/{token}") +def download(token: str) -> Response: + import time as _time + + _evict(_time.monotonic()) + held = _READY.get(token) + if held is None: + raise HTTPException( + 404, + "That repaired file is no longer being held. Repair the document again " + "to get a fresh copy — your original was never changed.", + ) + return Response( # repeat downloads are fine + held.blob, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f'attachment; filename="{held.filename}"'}, + ) + + +def main() -> None: + import uvicorn + + port = int(os.environ.get("PORT", "8000")) + uvicorn.run(app, host="127.0.0.1", port=port) + + +if __name__ == "__main__": + main() diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/static/index.html b/use-cases/Priyanshu2425/word-doc-repair/backend/static/index.html new file mode 100644 index 00000000..10439e7e --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/static/index.html @@ -0,0 +1,67 @@ + + + + + + + Salvage — recover a Word file that will not open + + + + + +
+ + diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/PKG-INFO b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/PKG-INFO new file mode 100644 index 00000000..7006e6ec --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/PKG-INFO @@ -0,0 +1,14 @@ +Metadata-Version: 2.4 +Name: word-doc-repair +Version: 1.0.0 +Summary: Best-effort recovery for a Word document that will not open. +Requires-Python: >=3.10 +License-File: LICENSE +Provides-Extra: web +Requires-Dist: fastapi; extra == "web" +Requires-Dist: uvicorn; extra == "web" +Requires-Dist: python-multipart; extra == "web" +Provides-Extra: dev +Requires-Dist: pytest; extra == "dev" +Requires-Dist: httpx2; extra == "dev" +Dynamic: license-file diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/SOURCES.txt b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/SOURCES.txt new file mode 100644 index 00000000..1558c936 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/SOURCES.txt @@ -0,0 +1,20 @@ +LICENSE +README.md +pyproject.toml +backend/docrepair/__init__.py +backend/docrepair/docx.py +backend/docrepair/engine.py +backend/docrepair/media.py +backend/docrepair/salvage.py +backend/docrepair/styled_export.py +backend/docrepair/superdocs_client.py +backend/docrepair/web.py +backend/word_doc_repair.egg-info/PKG-INFO +backend/word_doc_repair.egg-info/SOURCES.txt +backend/word_doc_repair.egg-info/dependency_links.txt +backend/word_doc_repair.egg-info/requires.txt +backend/word_doc_repair.egg-info/top_level.txt +tests/test_frontend_fixtures.py +tests/test_repair.py +tests/test_styled_export.py +tests/test_web.py \ No newline at end of file diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/dependency_links.txt b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/requires.txt b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/requires.txt new file mode 100644 index 00000000..5ef07755 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/requires.txt @@ -0,0 +1,9 @@ + +[dev] +pytest +httpx2 + +[web] +fastapi +uvicorn +python-multipart diff --git a/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/top_level.txt b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/top_level.txt new file mode 100644 index 00000000..75c11309 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/backend/word_doc_repair.egg-info/top_level.txt @@ -0,0 +1 @@ +docrepair diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/.gitignore b/use-cases/Priyanshu2425/word-doc-repair/frontend/.gitignore new file mode 100644 index 00000000..c05203af --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +tsconfig.tsbuildinfo diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/bundle-manifest.json b/use-cases/Priyanshu2425/word-doc-repair/frontend/bundle-manifest.json new file mode 100644 index 00000000..1baed4ea --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/bundle-manifest.json @@ -0,0 +1,4 @@ +{ + "sources": "22eac0ae0144a7e5066b0dfbad136e438293172b39488c9d79b1b9e57adc67b9", + "files": 10 +} diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/index.html b/use-cases/Priyanshu2425/word-doc-repair/frontend/index.html new file mode 100644 index 00000000..dc0cfb16 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + Salvage — recover a Word file that will not open + + + +
+ + + diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/package-lock.json b/use-cases/Priyanshu2425/word-doc-repair/frontend/package-lock.json new file mode 100644 index 00000000..95290cd7 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/package-lock.json @@ -0,0 +1,4093 @@ +{ + "name": "salvage-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "salvage-web", + "version": "1.0.0", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.10.2", + "jsdom": "^25.0.1", + "msw": "^2.7.0", + "playwright": "^1.62.1", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vite-plugin-singlefile": "^2.1.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.2.0.tgz", + "integrity": "sha512-SKXarWrYhtpqOEctf9XGCGy29QjsvJAM0Aq9ZR9z4Ns94OmpqudOly+aSEfNqUf9SwsQaUgY9+Z8hyzG0xX8fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.0.tgz", + "integrity": "sha512-+nnvFEXIB08CZNVXpvW3B+zHW96QXvGUjNKJ8NJIPqAZi5Kd4WhYt2S3C234ReepG1qw2HOlEUbjYVHBowXObA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "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==", + "dev": true, + "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/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "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.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "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.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.411", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "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.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "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.17", + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "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": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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==", + "dev": true, + "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/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "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.7", + "@vitest/ui": "3.2.7", + "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==", + "dev": true, + "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==", + "dev": true, + "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", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/package.json b/use-cases/Priyanshu2425/word-doc-repair/frontend/package.json new file mode 100644 index 00000000..f8d43343 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "salvage-web", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Salvage \u2014 the web page for the DOCX repair tool.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build && node scripts/manifest.mjs", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.10.2", + "jsdom": "^25.0.1", + "msw": "^2.7.0", + "playwright": "^1.62.1", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vite-plugin-singlefile": "^2.1.0", + "vitest": "^3.0.0" + } +} diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/manifest.mjs b/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/manifest.mjs new file mode 100644 index 00000000..a3e96a59 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/manifest.mjs @@ -0,0 +1,40 @@ +/** + * Records what the committed bundle was built from. + * + * The bundle is committed so a reviewer with no Node still gets the product. + * The cost of that decision is that the file can fall behind its source and + * nobody notices, so the build writes a hash of every source file and a pytest + * recomputes it. Stale bundle, failed build. + */ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const root = process.cwd(); +const roots = ["src", "index.html", "vite.config.ts", "package.json"]; + +function walk(path, out = []) { + const s = statSync(path); + if (s.isDirectory()) { + for (const entry of readdirSync(path).sort()) { + if (entry === "test" || entry === "fixtures" || entry === "node_modules") continue; + walk(join(path, entry), out); + } + } else { + out.push(path); + } + return out; +} + +const files = roots.flatMap((r) => walk(join(root, r))); +const hash = createHash("sha256"); +for (const file of files.sort()) { + hash.update(relative(root, file)); + hash.update(readFileSync(file)); +} + +writeFileSync( + join(root, "bundle-manifest.json"), + JSON.stringify({ sources: hash.digest("hex"), files: files.length }, null, 2) + "\n", +); +console.log("bundle-manifest written over", files.length, "source files"); diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/report-shot.mjs b/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/report-shot.mjs new file mode 100644 index 00000000..fd5cc933 --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/scripts/report-shot.mjs @@ -0,0 +1,11 @@ +import { chromium } from "playwright"; +const [, , url, fixture, out, w = "900", h = "1200"] = process.argv; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: +w, height: +h }, deviceScaleFactor: 2 }); +await page.goto(url, { waitUntil: "networkidle" }); +await page.setInputFiles('input[type="file"]', fixture); +await page.waitForSelector(".stamp", { timeout: 15000 }); +await page.waitForTimeout(700); +await page.screenshot({ path: out, fullPage: true }); +await browser.close(); +console.log("wrote", out); diff --git a/use-cases/Priyanshu2425/word-doc-repair/frontend/src/App.tsx b/use-cases/Priyanshu2425/word-doc-repair/frontend/src/App.tsx new file mode 100644 index 00000000..b38be6cd --- /dev/null +++ b/use-cases/Priyanshu2425/word-doc-repair/frontend/src/App.tsx @@ -0,0 +1,282 @@ +/* ============================================================================ + DIRECTION CONTRACT + + THESIS: What an institution sends you when your item arrived damaged. This is + a recovery notice with a docket number and a stamped verdict, not the SaaS + uploader page — no hero, no feature cards, no dashed rectangle. + + OWN-WORLD: Cool form stock, black print, postal blue for the institution and + postal red for the damage. Airmail chevron tape edges anything we have taken + custody of. A rubber-stamped verdict lands once. Mono only for the record + line: filename, size, docket. + + STORY: Someone whose only copy will not open hands it over, watches it pass + through real stations, reads a stamped verdict and two lists — what came + through and what did not — and takes the file back. + + FIRST VIEWPORT: Wordmark, one sentence of what this is, the handover panel at + full width, and the custody promises directly beneath it: original never + changed, nothing kept, no account. + + FORM: Postal damaged-item recovery notice. Seventh on my grounded list, which + is the one the seed assigned (key e3d60a22, direction scope, persuade). + ========================================================================== */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + capabilities, + checkFile, + repairFile, + RepairError, + type Capabilities, + type Report as ReportT, + type Stage, +} from "./lib/repair"; +import { Report } from "./components/Report"; + +type Phase = "idle" | "working" | "done"; + +function docket(name: string): string { + // A docket number is a receipt, not an identifier the server knows about. + let h = 0; + for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 99991; + return `SLV-${String(h).padStart(5, "0")}`; +} + +function readableSize(bytes: number): string { + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function App() { + const [phase, setPhase] = useState("idle"); + const [stages, setStages] = useState([]); + const [report, setReport] = useState(null); + const [problem, setProblem] = useState(null); + const [handed, setHanded] = useState<{ name: string; size: number } | null>(null); + const [over, setOver] = useState(false); + // Asked once, before anything is offered. A page that advertises a step it + // cannot take is the button that fails when somebody presses it. + const [can, setCan] = useState({ styling: false, note: "" }); + const input = useRef(null); + + useEffect(() => { + let live = true; + void capabilities().then((c) => { + if (live) setCan(c); + }); + return () => { + live = false; + }; + }, []); + + const start = useCallback(async (file: File) => { + const refusal = checkFile(file); + setProblem(refusal); + if (refusal) return; + + setHanded({ name: file.name, size: file.size }); + setStages([]); + setReport(null); + setPhase("working"); + try { + const result = await repairFile(file, (s) => setStages((prev) => [...prev, s])); + setReport(result); + setPhase("done"); + } catch (e) { + setProblem( + e instanceof RepairError + ? e.message + : "Something went wrong before your file could be read. Nothing was changed.", + ); + setPhase("idle"); + } + }, []); + + const another = useCallback(() => { + setPhase("idle"); + setStages([]); + setReport(null); + setProblem(null); + setHanded(null); + if (input.current) input.current.value = ""; + }, []); + + return ( + <> +
+
+

+ Salvage. +

+ document recovery +
+ +
+
+
+ {phase === "idle" ? ( + <> +

Your Word file will not open.

+

+ Hand it over and this page will read whatever is still intact inside it, rebuild + what it can, and tell you plainly what it could not. +

+ + + + {problem ? ( +

+ That file was not taken. + {problem} +

+ ) : null} + +
    +
  • +