From d0dcddbae00716c65c2fc25cc3cecdc73393d5f6 Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:36:55 +0200 Subject: [PATCH 1/6] Fix external Graphify inference with Codex and a public bundled runtime --- .github/workflows/quality.yml | 1 + .github/workflows/windows.yml | 5 +- CHANGELOG.md | 9 ++ GraphPaper.pyw | 2 +- README.md | 2 + docs/ARCHITECTURE.md | 6 +- docs/CONNECTIONS.md | 4 + docs/DEVELOPMENT.md | 4 + docs/GRAPH-AND-JEV.md | 4 +- docs/INTEGRATIONS.md | 16 +- docs/README.md | 2 +- docs/RELEASE-0.3.1.md | 27 ++++ docs/SECURITY.md | 4 +- docs/UPDATE-0.3.1.md | 77 ++++++++++ docs/WINDOWS.md | 4 + docs/wiki-pages.json | 2 +- graphpaper/__init__.py | 2 +- graphpaper/codex.py | 15 +- graphpaper/graph.py | 3 +- graphpaper/graphify_adapter.py | 234 ++++++++++++++++++++++-------- graphpaper/graphify_gateway.py | 168 +++++++++++++++++++++ graphpaper/graphify_process.py | 87 +++++++++++ graphpaper/graphify_runtime.py | 76 ++++++++++ graphpaper/graphify_worker.py | 62 ++++++++ graphpaper/models.py | 4 + graphpaper/pipeline.py | 31 +++- graphpaper/server.py | 6 + publish-manifest.json | 81 +++++++---- pyproject.toml | 7 +- requirements-desktop.txt | 1 + requirements-dev.txt | 2 + requirements-graphify.txt | 4 + scripts/build_release.ps1 | 8 +- scripts/check_graphify.py | 108 ++++++++++++++ scripts/desktop_entry.py | 6 + scripts/graphify_ui_checks.py | 75 ++++++++++ scripts/hooks/hook-graphify.py | 20 +++ scripts/native_smoke.py | 22 ++- scripts/prepare_assets.py | 2 +- scripts/prepare_graphify.py | 70 +++++++++ scripts/ui_graphify_smoke.py | 51 +++++++ tests/graphify_fixtures.py | 49 +++++++ tests/graphify_http_fixture.py | 38 +++++ tests/test_graphify_extraction.py | 130 +++++++++++++++++ tests/test_graphify_gateway.py | 57 ++++++++ tests/test_graphify_provenance.py | 27 ++++ tests/test_graphify_validation.py | 55 +++++++ tests/test_reasoning_controls.py | 3 +- ui/app.js | 2 +- ui/graphify.js | 10 ++ ui/index.html | 1 + 51 files changed, 1548 insertions(+), 138 deletions(-) create mode 100644 docs/RELEASE-0.3.1.md create mode 100644 docs/UPDATE-0.3.1.md create mode 100644 graphpaper/graphify_gateway.py create mode 100644 graphpaper/graphify_process.py create mode 100644 graphpaper/graphify_runtime.py create mode 100644 graphpaper/graphify_worker.py create mode 100644 requirements-graphify.txt create mode 100644 scripts/check_graphify.py create mode 100644 scripts/graphify_ui_checks.py create mode 100644 scripts/hooks/hook-graphify.py create mode 100644 scripts/prepare_graphify.py create mode 100644 scripts/ui_graphify_smoke.py create mode 100644 tests/graphify_fixtures.py create mode 100644 tests/graphify_http_fixture.py create mode 100644 tests/test_graphify_extraction.py create mode 100644 tests/test_graphify_gateway.py create mode 100644 tests/test_graphify_provenance.py create mode 100644 tests/test_graphify_validation.py create mode 100644 ui/graphify.js diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7db687d..4c0bca8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -39,6 +39,7 @@ jobs: - run: python scripts/ui_smoke.py - run: python scripts/ui_studio_smoke.py - run: python scripts/ui_science_smoke.py + - run: python scripts/ui_graphify_smoke.py - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 3399185..6574fa6 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -19,6 +19,7 @@ jobs: - run: python scripts/ui_smoke.py - run: python scripts/ui_studio_smoke.py - run: python scripts/ui_science_smoke.py + - run: python scripts/ui_graphify_smoke.py windows: needs: quality runs-on: windows-latest @@ -36,7 +37,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: ./scripts/build_release.ps1 - name: Exercise the actual Windows window, native dialogs and save-on-close - run: python scripts/native_smoke.py --science --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package + run: python scripts/native_smoke.py --science --graphify --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package - uses: actions/upload-artifact@v4 with: name: GraphPaper-Windows-x64 @@ -52,7 +53,7 @@ jobs: $version = python -c "from graphpaper import __version__; print(__version__)" if ($env:GITHUB_REF_NAME -ne "v$version") { throw 'Tag does not match application version.' } gh release view $env:GITHUB_REF_NAME *> $null - if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --verify-tag --title "GraphPaper $env:GITHUB_REF_NAME" --notes-file docs/RELEASE-0.3.0.md } + if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --verify-tag --title "GraphPaper $env:GITHUB_REF_NAME" --notes-file docs/RELEASE-0.3.1.md } $packageName = "GraphPaper-v$version-Windows-x64.zip" $release = gh release view $env:GITHUB_REF_NAME --json assets | ConvertFrom-Json if ($release.assets.name -contains $packageName) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8b53a..89fa623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.3.1 + +- Repair external Graphify extraction with the configured provider, including official Codex OAuth and extraction-role reasoning. +- Bundle pinned public Graphify 0.9.80, SDKs, metadata, tokenizer cache and licences in Windows builds; publish explicit source requirements. +- Route job-scoped inference through an authenticated loopback adapter, with shared accounting, no API fallback and no duplicate Native extraction. +- Preserve source-file provenance into graph inspection and angle retrieval; reject malformed/partial graph results. +- Add runtime diagnostics, deadline/cancellation coverage, compatibility tests, actual external-worker tests and compiled-native release gates. + + ## 0.3.0 - Provider-native, model-aware reasoning effort for writer/editor/extraction roles, with Codex catalog support and explicit thinking budgets. diff --git a/GraphPaper.pyw b/GraphPaper.pyw index 38fd65b..5743998 100644 --- a/GraphPaper.pyw +++ b/GraphPaper.pyw @@ -18,7 +18,7 @@ CONSOLE_PYTHON = RUNTIME / ("Scripts/python.exe" if os.name == "nt" else "bin/py STAMP = RUNTIME / "graphpaper-requirements.sha256" NO_WINDOW = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 requirements = ROOT / "requirements-desktop.txt" -fingerprint = hashlib.sha256(requirements.read_bytes() + (ROOT / "requirements.txt").read_bytes()).hexdigest() +fingerprint = hashlib.sha256(requirements.read_bytes() + (ROOT / "requirements.txt").read_bytes() + ((ROOT / "requirements-graphify.txt").read_bytes() if (ROOT / "requirements-graphify.txt").exists() else b"")).hexdigest() def launch(): diff --git a/README.md b/README.md index 0dbc602..7685b06 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ GraphPaper is a **local-first writing studio for nonfiction, fiction and scienti Use **Codex / ChatGPT sign-in** or your own model API. Keep control of the argument, the evidence and the final words. +**External Graphify now supports Codex sign-in and per-model reasoning.** The Windows release includes the public runtime; there is no extra API-key requirement or duplicate Native pass. [Graphify setup and distribution](docs/UPDATE-0.3.1.md) + ## Three ways to write | Mode | Start with | Build toward | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 15c5534..dc326c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,6 +76,10 @@ The optional automatic revision is bounded, not an open-ended agent loop. JEV ju Graph view limits are distinct from stored-graph size. Character budgets are not exact tokenizer counts for every model. Source packs contain selected passages rather than a promise that every request sees the entire library. A voice profile is source-informed prompting, not fine-tuning. -Graphify is optional external execution with separate costs. Scholarly records, preprints and abstract-only sources are not automatically validated science. Citation matching samples support; it cannot prove every inference. +Graphify is an optional graph engine with a bundled public runtime in Windows releases. Its external worker calls a job-scoped loopback gateway; GraphPaper routes the selected extraction provider/model/effort and records the usage. There is no duplicate Native pass. Scholarly records, preprints and abstract-only sources are not automatically validated science. Citation matching samples support; it cannot prove every inference. The application is not currently documented as a multi-user server, an MCP service or an installed agent plugin. Future integrations should preserve explicit project permission and avoid exposing credentials. [Development](DEVELOPMENT.md) and [Security](SECURITY.md) describe the corresponding checks. + +## External Graphify runtime boundary + +`graphify_worker.py` hosts the unmodified pinned public CLI in a separate process. `graphify_gateway.py` implements its authenticated, text-only loopback completion endpoint. `graphify_runtime.py` probes availability and prepares an isolated environment; `graphify_process.py` manages owned process lifetimes. The adapter validates the complete graph before the existing optimistic project save. File provenance remains a retrieval pointer, not a truth status. [Protocol and distribution](UPDATE-0.3.1.md). diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 92e23a3..7185d14 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -66,3 +66,7 @@ Cloud generation requires explicit permission to send project material. URL impo Advanced settings include maximum requests per job, output-token cap, context budget in **characters**, candidate count and request timeout. A request limit is not a monetary cap. Provider-reported cost is displayed when supplied; missing cost stays unknown. Cancellation cannot undo a request already submitted. Never put keys into prompts, screenshots, issue reports or project documents. [Credential storage and privacy →](SECURITY.md) + +## External Graphify in 0.3.1 + +Choose **Graphify (external process)** under Advanced limits & Graphify. Leave the custom path blank for the included Windows runtime. **Check Graphify runtime** does not call a model. The external action now uses this same provider connection, including Codex OAuth, with the extraction-role model and reasoning setting. [Detailed setup and distribution](UPDATE-0.3.1.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 19ebbf4..59e9c51 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -85,3 +85,7 @@ Preserve user artwork and verify its path/hash when reorganizing README content. Open a focused issue or pull request explaining the user-visible behavior, the change and the checks you actually ran. Include a non-confidential reproduction when useful. Do not commit runtime binaries, test environments, credentials, manuscript databases or real private source documents. For application releases, continue with [Packaging](PUBLISHING.md). For sensitive disclosures, use a private route to the maintainer and read [Security](SECURITY.md). + +## External Graphify in 0.3.1 + +The full developer/test requirements include the public pinned Graphify distribution. Run `python scripts/prepare_graphify.py` for tokenizer/licence assets, `python scripts/check_graphify.py` for an isolated real-worker test with synthetic inference, and `python scripts/ui_graphify_smoke.py` for its graphical workflow. Windows builds also test the frozen worker. Opt-in live Codex testing is documented in the check script and requires an explicitly selected existing official account profile. [Detailed setup and distribution](UPDATE-0.3.1.md). diff --git a/docs/GRAPH-AND-JEV.md b/docs/GRAPH-AND-JEV.md index f91e911..4baf398 100644 --- a/docs/GRAPH-AND-JEV.md +++ b/docs/GRAPH-AND-JEV.md @@ -8,7 +8,7 @@ The graph is an inspectable map of ideas and possible relationships. It helps di **Import graph** accepts Graphify/NetworkX-style node-link JSON with `nodes` and either `edges` or `links`. Imported relationships remain unverified. External confidence values are not promoted to evidence. -**Graphify CLI** is optional for an existing trusted installation. It runs separately, with its own model requests, and native extraction adds source checking. It is not bundled with the Windows release. [Technical integration details →](INTEGRATIONS.md#graphify) +**Graphify (external process)** uses the public Graphify 0.9.80 runtime included with the Windows release. It builds the graph separately, while GraphPaper supplies your selected extraction model and reasoning setting, including Codex OAuth, and accounts for the requests. No second Native pass runs. Leave the custom executable path blank for the bundled runtime. [Setup and public dependency strategy](UPDATE-0.3.1.md) ## Read the status, not only the picture @@ -20,7 +20,7 @@ The graph is an inspectable map of ideas and possible relationships. It helps di | **Proposed** | A possibility to develop or examine | | **Imported** | External graph content that has not been verified against this project’s sources | -Quoted text is checked for occurrence in the imported source. It can still be misleading, disputed or insufficient to support the claim. Read the surrounding passage. +External Graphify nodes also retain source-file links. These pointers support retrieval for angle evaluation without pretending the file proves a relationship. Quoted text is checked for occurrence in the imported source. It can still be misleading, disputed or insufficient to support the claim. Read the surrounding passage. ## Navigate and guide discovery diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index cb864ae..33c1835 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -1,6 +1,6 @@ # Integration reference -This page describes the interfaces implemented in GraphPaper 0.3.0. User-facing setup is in [Models and reasoning](CONNECTIONS.md). External endpoints, model IDs and capability vocabularies can change independently; consult the linked primary documentation and test a connection before a large job. +This page describes the interfaces implemented in GraphPaper 0.3.1. User-facing setup is in [Models and reasoning](CONNECTIONS.md). External endpoints, model IDs and capability vocabularies can change independently; consult the linked primary documentation and test a connection before a large job. ## Writing providers @@ -46,19 +46,17 @@ Primary references: [NCBI](https://www.ncbi.nlm.nih.gov/home/develop/api/), [Sem ## Graphify -Native extraction does not require Graphify. Imported Graphify/NetworkX JSON supports `nodes` and either `edges` or `links`; relationships are not accepted as factual evidence merely because the JSON supplies a confidence value. +Native extraction remains independently available. External extraction runs the unmodified public `graphifyy[openai]==0.9.80` CLI. The Windows package includes its dependencies, metadata, tokenizer cache and upstream licences; source environments use [requirements-graphify.txt](../requirements-graphify.txt). -The optional CLI adapter invokes a user-selected executable without a shell, prepares enabled non-voice source text in a temporary folder and selects the configured provider backend. Its command contract includes: +The worker receives only an isolated non-voice text corpus and a per-job loopback credential. It calls Graphify's OpenAI-compatible backend, but the authenticated local gateway routes inference through **GraphPaper's selected extraction provider, model and reasoning setting**. This includes official Codex OAuth, OpenRouter, API-compatible and Anthropic connections. No real provider/OAuth credential is passed to Graphify, and no API-key fallback is introduced. -```text -graphify extract --backend openai --mode deep --no-viz --no-cluster --token-budget 4000 --max-concurrency 2 -``` +Graphify requests share GraphPaper's budget and receipts. Extraction is serial; upstream retry layers are disabled to avoid multiplying requests. The total deadline and cancellation cover owned workers. A previously submitted cloud request can still consume allowance after cancellation. Invalid, missing, oversized, empty or dangling graph output does not replace the previous graph. No second Native extraction is run. -For direct Anthropic it selects the corresponding Claude backend. The adapter looks for `graphify-out/graph.json` or `graph.json` and adds native evidence extraction. Version compatibility must be checked against the installed Graphify executable. Graphify is not bundled, and the external process has not been validated by a live run in the recorded GraphPaper release tests. +The output remains an external interpretation. Source-file IDs are retained as retrieval pointers, not truth labels; exact quotation anchors are checked separately. The native graph UI and angle discovery use the completed result. -Graphify’s calls and retries are outside GraphPaper’s internal request/cost meter. Codex subscription sign-in is not a drop-in API credential for this external adapter; use native extraction with Codex or provide the external process with its supported API connection. +A custom executable is an explicit trust choice and must match the supported upstream version. Its version check is not a full audit of that custom environment. [Complete protocol, runtime and distribution guide](UPDATE-0.3.1.md) -Project references: [Graphify repository](https://github.com/Graphify-Labs/graphify), [Graphify documentation](https://docs.graphify.com). +Primary references: [public Graphify distribution](https://pypi.org/project/graphifyy/0.9.80/), [upstream source](https://github.com/Graphify-Labs/graphify), [Codex non-interactive execution](https://developers.openai.com/codex/noninteractive). ## Local API and desktop bridge diff --git a/docs/README.md b/docs/README.md index e76f94b..95668ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ **A studio for connected thought.** GraphPaper brings source material, graph exploration and a writing desk into one local-first Windows application. -These guides describe **GraphPaper 0.3.0**. The [release page](https://github.com/AronAxe/GraphPaper/releases/latest) provides the current Windows download; [CHANGELOG](../CHANGELOG.md) records version history. The same guides are published in the [GitHub wiki](https://github.com/AronAxe/GraphPaper/wiki). +These guides describe **GraphPaper 0.3.1**. The [release page](https://github.com/AronAxe/GraphPaper/releases/latest) provides the current Windows download; [CHANGELOG](../CHANGELOG.md) records version history. The same guides are published in the [GitHub wiki](https://github.com/AronAxe/GraphPaper/wiki). ## Start here diff --git a/docs/RELEASE-0.3.1.md b/docs/RELEASE-0.3.1.md new file mode 100644 index 0000000..4f84ec3 --- /dev/null +++ b/docs/RELEASE-0.3.1.md @@ -0,0 +1,27 @@ +# GraphPaper 0.3.1 - external Graphify with Codex + +## Fixed + +- The external Graphify action now works with **Codex / ChatGPT OAuth** and the configured GraphPaper provider, extraction model and reasoning setting. +- Graphify runs as the real public upstream command in a separate process. There is **no duplicate Native extraction pass**, no requirement for an additional Codex API key and no automatic switch to API billing. +- The Windows application includes **Graphify 0.9.80**, its public dependencies, metadata, tokenizer cache and licences. Source dependencies are pinned in `requirements-graphify.txt`; no private fork or unpublished backend is needed. +- A temporary, authenticated loopback adapter routes Graphify's bounded text requests through GraphPaper. Provider/OAuth credentials are not passed to Graphify. The extraction shares the job's request accounting and honours configured reasoning. +- Missing backends, malformed/empty graph output, dangling edges, cancellation and timeouts leave the previous graph and source documents intact. +- External source-file provenance is preserved in the graph inspector and downstream angle retrieval without treating it as proof of a claim. +- Added graphical runtime diagnostics, total extraction timeout and chunk-budget controls, plus release gates that actually exercise the bundled worker rather than merely checking the window's HTTP assets. + +The existing author-voice, project-folder, humanizer/deslop, Science/APA and native Windows freeze fixes remain in place. + +## Download and use + +Extract **GraphPaper-v0.3.1-Windows-x64.zip** into a new folder and run **GraphPaper.exe**. Keep `_internal` beside it. The binary is unsigned; no separate Python or Graphify installation is required for this Windows package. + +In **Connections & settings**, keep or select your provider, choose the extraction model and reasoning depth, and select **Graphify (external process)** under **Advanced limits & Graphify**. Leave **Custom Graphify executable** blank for the included runtime. An old explicit custom path is not silently overridden; clear it when switching to the bundled runtime. **Check Graphify runtime** makes no model call. + +## Testing and scope + +Validation separates implementation tests, real upstream Graphify execution, a real Codex-backed extraction, and compiled Windows/native checks. Model fixtures are explicitly synthetic. The real Codex test uses synthetic documents in an isolated project, not an existing manuscript. Versioned reports record the exact platforms, outcomes and archive checksum. + +Cancellation cannot refund a provider request that was already submitted. File attribution and quote matching do not prove scientific validity. macOS compatibility is not presented as a tested desktop build. Existing local installations are not modified by publishing this release. + +[Integration and distribution guide](https://github.com/AronAxe/GraphPaper/blob/main/docs/UPDATE-0.3.1.md) · [Public Graphify distribution](https://pypi.org/project/graphifyy/0.9.80/) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0add2e1..a3b8561 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Do not paste tokens into manuscript prompts, source documents, command examples | Public URL import / author-page discovery | The requested URL and ordinary network request information | | Scholarly search / full-text retrieval | Search terms, requested identifiers, and an optional service credential | | Codex browser sign-in | The official account-authentication flow handled by Codex | -| Optional Graphify subprocess | A temporary source corpus and the configured provider connection | +| External Graphify subprocess | Enabled non-voice source text and a temporary loopback credential; inference is routed by GraphPaper | Cloud generation requires permission. Website imports and scholarly searches are distinct user-triggered network operations and do not require an LLM key. Provider processing, retention, account and rate-limit policies still apply. The app has no analytics or hidden automatic publishing. @@ -47,7 +47,7 @@ Inbox import checks stable files and rejects unsafe filesystem cases such as sym ## Optional executables and release integrity -Graphify is a separate executable selected by the user. Its behavior, retries and child processes are outside GraphPaper’s internal metering. Use a trusted installation or native extraction instead. +The included public Graphify runtime runs as an owned child process with a temporary configuration home. It receives source text and a job-specific loopback credential, not real provider or OAuth credentials. Inference passes through GraphPaper and shares its request accounting. Native process cleanup is lifecycle management, not a hostile-code sandbox: a custom executable is still an explicit trust choice. See [external Graphify](UPDATE-0.3.1.md). The Windows release bundles an official Codex runtime with version and checksum provenance. `SHA256SUMS.txt` identifies the exact application ZIP. The GraphPaper binary is unsigned; a checksum is not a signed publisher identity or an independent audit. diff --git a/docs/UPDATE-0.3.1.md b/docs/UPDATE-0.3.1.md new file mode 100644 index 0000000..fbef858 --- /dev/null +++ b/docs/UPDATE-0.3.1.md @@ -0,0 +1,77 @@ +# GraphPaper 0.3.1: external Graphify with Codex + +This update repairs the external Graphify action. It uses the actual, unmodified public **Graphify 0.9.80** command, not a Native extraction pass relabelled as Graphify and not an unpublished fork. + +## Using the Windows release + +Open **Connections & settings** and select your writing provider. Codex users can keep **Sign in with ChatGPT**. Choose the extraction model and its reasoning level, then select **Graphify (external process)** in **Advanced limits & Graphify**. + +Leave **Custom Graphify executable** blank to use the runtime included in the Windows package. Use **Check Graphify runtime** to verify it without making a model call. An explicit custom path is respected; an invalid path or unsupported version is not silently replaced. Clear an old custom path when switching to the included runtime. + +Open a project and choose **Build graph**. Only enabled non-voice source text is exported into the job's temporary input directory. Graphify runs in a separate process and returns its graph to GraphPaper. File references point back to the original project sources. The source library and settings are not replaced. + +The result feeds the ordinary graph, angle and writing workflow. File provenance supplies bounded original passages for angle evaluation; it is not automatically promoted to verified evidence. No second Native extraction is run after Graphify. + +## Codex, other providers and reasoning + +Graphify's public OpenAI-compatible backend connects to a temporary local GraphPaper endpoint. GraphPaper supplies the inference through its selected extraction provider: + +- **Codex / ChatGPT OAuth:** the official Codex runtime performs the text-only completion using the existing signed-in account. No additional writing API key or API-billing fallback is introduced. +- **OpenRouter, OpenAI-compatible and Anthropic:** the existing GraphPaper provider adapters retain their selected model, reasoning controls, consent and request limits. + +The extraction model is used when set; otherwise the writer model is used. **The extraction reasoning setting is used**, not the writer's or editor's setting. Provider default still sends no reasoning override. Unsupported model/effort combinations fail rather than silently substituting a lower setting. + +The child receives a random, job-specific local credential and a fixed local model alias. It does not receive the actual provider API key or Codex OAuth credentials. The local endpoint accepts only the bounded text-extraction request shape, listens only on loopback, rejects browser-origin requests and does not expose tools, an arbitrary model router or an arbitrary upstream URL. + +This is a controlled integration, not an operating-system security sandbox. Only run a custom executable you trust. Native process ownership and a temporary configuration home reduce accidental interaction with unrelated work; they are not a claim that hostile programs cannot access the computer under the current user account. + +## Budgets, cancellation and failures + +Graphify's inference passes through GraphPaper's existing request accounting. There is one inference request at a time, no separate unmetered API path, no second Native pass, and no hidden API-key fallback. Graphify SDK and chunk-retry settings are disabled so the two systems do not multiply retries. GraphPaper's own documented bounded provider handling remains in effect. + +The UI exposes the **total extraction timeout** and Graphify's approximate input-chunk budget. The selected provider's request timeout and output limit still apply where supported by that provider. An output-token limit is not an exact input tokenizer or a guaranteed monetary cap. + +Cancellation stops the owned Graphify process tree and signals the in-flight inference. Official Codex execution checks this cancellation while waiting. A submitted cloud API request may already have consumed tokens or may finish after cancellation; cancelling is not a billing reversal. Late results cannot overwrite the old project graph. + +Malformed JSON, non-finite values, empty graphs, duplicate node IDs, dangling edges, missing output and oversized output are rejected before the result is committed. A failed extraction leaves the prior source documents, graph and manuscript intact. Usage and failure history can still record the attempt. + +## Public dependency and distribution strategy + +The source dependency is declared in [requirements-graphify.txt](../requirements-graphify.txt). It uses the public PyPI distribution `graphifyy[openai]==0.9.80`; the verified SDK/tokenizer versions are pinned alongside it. No private repository, local-only backend patch or unreleased upstream feature is required. + +For a source environment: + +```bash +python -m pip install -r requirements-graphify.txt +python scripts/prepare_graphify.py +``` + +The graphical Windows source launcher installs its desktop requirements, including the public Graphify dependencies. Its dependency fingerprint includes the Graphify requirements file. Native-only Python environments may omit this optional runtime, but cannot claim external Graphify is available until its probe succeeds. + +The **Windows ZIP includes the Graphify Python modules, runtime dependencies, distribution metadata, tokenizer cache, upstream licences/notices and official Codex executable**. GraphPaper launches a dedicated worker mode of its own executable to host the unmodified Graphify CLI; it does not require a separate system Python or Graphify installation. + +Release preparation verifies installed dependency versions, prepares the tokenizer cache and requires the upstream licence. The package contains `vendor/graphify-runtime.json` inside its bundled assets, recording versions, public provenance and cache/licence checksums. Packaging tests must exercise the frozen Graphify worker, not merely check that the main window's HTTP assets load. + +A custom Graphify executable must match the supported release and have its OpenAI-compatible SDK dependencies installed. Its version probe cannot establish the health of an arbitrary custom environment; the included runtime has the stronger package-level verification. + +## Validation scope + +The tests distinguish these operations: + +1. Pure validation and backward-compatible data defaults. +2. The real public Graphify process with synthetic provider responses, including failure, cancellation, timeout, source exclusion and model/effort routing. +3. A real Codex-backed extraction through GraphPaper's external action, with synthetic documents and an isolated project. +4. The compiled Windows worker and actual native Windows interface. + +The recorded live Codex test uses an explicitly selected model/effort rather than silently changing the user's settings. It sends only the synthetic test material. No installed application update or experiment on an existing manuscript is part of this delivery. + +See the versioned validation reports for actual platform and packaging results. Windows native execution is distinct from Linux source tests and from compatibility inferred for macOS. Do not interpret source compatibility as a tested macOS desktop package. + +## Primary references + +- [Public graphifyy distribution](https://pypi.org/project/graphifyy/0.9.80/) +- [Graphify upstream source](https://github.com/Graphify-Labs/graphify) +- [Graphify documentation](https://docs.graphify.com) +- [Codex non-interactive execution](https://developers.openai.com/codex/noninteractive) +- [Codex app-server and model discovery](https://developers.openai.com/codex/app-server) +- [Codex reasoning configuration](https://developers.openai.com/codex/config-reference) diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 79dde58..d3375a6 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -46,3 +46,7 @@ Normal compiled-release users do not need the `.vbs` launcher, the optional publ The normal log directory is `%LOCALAPPDATA%\GraphPaper`. Logs may contain diagnostics; inspect them before sharing. Do not upload the full data directory or credential files to an issue. For model, import and manuscript problems, continue with [Troubleshooting](TROUBLESHOOTING.md). For developer builds and native checks, see [Releases and packaging](PUBLISHING.md). + +## External Graphify in 0.3.1 + +The 0.3.1 Windows package includes the public Graphify runtime and its dependencies. No separate Graphify installation is required. Clear an old custom executable path to select the included runtime; custom paths are never silently overridden. [Detailed setup and distribution](UPDATE-0.3.1.md). diff --git a/docs/wiki-pages.json b/docs/wiki-pages.json index 3608935..2a5bb67 100644 --- a/docs/wiki-pages.json +++ b/docs/wiki-pages.json @@ -1,6 +1,6 @@ { "repository": "AronAxe/GraphPaper", - "docs_version": "0.3.0", + "docs_version": "0.3.1", "pages": [ { "title": "Home", diff --git a/graphpaper/__init__.py b/graphpaper/__init__.py index 2d917b1..00306c9 100644 --- a/graphpaper/__init__.py +++ b/graphpaper/__init__.py @@ -1,2 +1,2 @@ """GraphPaper: an evidence-aware, graph-native writing studio.""" -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/graphpaper/codex.py b/graphpaper/codex.py index 9b2d4c3..afc669c 100644 --- a/graphpaper/codex.py +++ b/graphpaper/codex.py @@ -14,6 +14,7 @@ import webbrowser from urllib.parse import urlsplit from .providers import ProviderError, Cancelled +from .graphify_process import OwnedProcess REGISTRY = {} REGISTRY_LOCK = threading.RLock() @@ -70,7 +71,7 @@ def start(self): stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL, text=True,encoding='utf-8',errors='strict',bufsize=1,creationflags=flags) threading.Thread(target=self._read,args=(self.proc,),daemon=True,name='graphpaper-codex-auth').start() - self.rpc('initialize',{'clientInfo':{'name':'graphpaper','title':'GraphPaper','version':'0.3.0'}}) + self.rpc('initialize',{'clientInfo':{'name':'graphpaper','title':'GraphPaper','version':'0.3.1'}}) self._send({'method':'initialized','params':{}}) def _send(self, value): @@ -181,8 +182,9 @@ def complete(self, system, user, model, job=None, max_calls=80, reasoning_effort prompt = ('You are the prose/structured-output engine inside GraphPaper. This is a text transformation, not a coding task. ' 'Do not use tools, inspect files, execute commands or access the network. Return only the requested answer.\n\n' + system + '\n\n' + user) - proc = subprocess.Popen(args,cwd=temp,env=self.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE, - text=True,encoding='utf-8',errors='replace',creationflags=flags) + owned = OwnedProcess(args,cwd=temp,env=self.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE, + text=True,encoding='utf-8',errors='replace') + proc = owned.proc started = time.monotonic() try: first = True @@ -216,12 +218,7 @@ def complete(self, system, user, model, job=None, max_calls=80, reasoning_effort raise ProviderError('Codex returned an empty answer.') return text finally: - if proc.poll() is None: - proc.terminate() - try: - proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill();proc.communicate() + owned.close() def close(self): with self.lock: diff --git a/graphpaper/graph.py b/graphpaper/graph.py index fa6b7eb..ffcd402 100644 --- a/graphpaper/graph.py +++ b/graphpaper/graph.py @@ -130,6 +130,7 @@ def import_graph(raw: dict) -> Graph: def mine_motifs(project: Project, limit=60) -> list[dict]: """Bounded motif discovery; graph structure nominates, never proves, a thesis.""" + eligible_sources = {s.id for s in project.sources if s.enabled and s.role != "voice"} excluded = set(project.brief.excluded_nodes) nodes = {n.id: n for n in project.graph.nodes if n.id not in excluded} edges = [e for e in project.graph.edges if e.source in nodes and e.target in nodes] @@ -147,7 +148,7 @@ def add(kind, ns, es): seen.add(key) ns = list(dict.fromkeys(ns)) es = list({e.id: e for e in es}.values()) - source_ids = sorted({v.source_id for e in es for v in e.evidence if v.verified} | {v.source_id for n in ns for v in nodes[n].evidence if v.verified}) + source_ids = sorted(({v.source_id for e in es for v in e.evidence if v.verified} | {v.source_id for n in ns for v in nodes[n].evidence if v.verified} | {sid for e in es for sid in e.source_ids} | {sid for n in ns for sid in nodes[n].source_ids}) & eligible_sources) pin = sum(n in project.brief.pinned_nodes for n in ns) tension = kind in {"contradiction", "conflict", "divergence", "cross-community bridge"} rank = pin * 5 + len(source_ids) * 2 + int(tension) * 2 + min(len(ns), 5) diff --git a/graphpaper/graphify_adapter.py b/graphpaper/graphify_adapter.py index ab729d5..4de0abd 100644 --- a/graphpaper/graphify_adapter.py +++ b/graphpaper/graphify_adapter.py @@ -1,70 +1,186 @@ -"""Optional real Graphify CLI bridge; user-selected executable, no shell commands.""" +"""Actual public Graphify CLI extraction, using GraphPaper's configured inference. + +Graphify builds the graph in a child process. A job-scoped loopback gateway +supplies text completions, including official Codex/ChatGPT OAuth completions. +No private Graphify fork or API-key fallback is required. +""" from __future__ import annotations +import copy import json -import os -import shutil +from pathlib import Path import subprocess import tempfile +import threading import time -from pathlib import Path from .graph import import_graph -from .models import Project -from .providers import Clients, ProviderError +from .models import Evidence, now +from .providers import ProviderError, Cancelled +from .graphify_gateway import Gateway, JobScope +from .graphify_process import OwnedProcess +from .graphify_runtime import check_runtime, isolated_environment +from .graphify_worker import PINNED_VERSION + +MAX_GRAPH_BYTES=20_000_000 + + +def validate_graph(raw): + """Fail closed on malformed/dangling graphs; never drop broken rows silently.""" + if not isinstance(raw,dict) or not isinstance(raw.get('nodes'),list): + raise ValueError('Graphify output must contain a nodes array.') + edges=raw.get('edges',raw.get('links')) + if not isinstance(edges,list): raise ValueError('Graphify output must contain edges or links.') + if not raw['nodes']: raise ValueError('Graphify returned an empty graph; the previous graph was preserved.') + if len(raw['nodes'])>5000 or len(edges)>20000: raise ValueError('Graphify output exceeds the graph size limits.') + ids=set() + for node in raw['nodes']: + if not isinstance(node,dict) or not isinstance(node.get('id'),(str,int)) or isinstance(node['id'],bool): + raise ValueError('Graphify returned an invalid node ID.') + ident=str(node['id']) + if not ident or len(ident)>400 or ident in ids: raise ValueError('Graphify node IDs must be unique and nonempty.') + label=node.get('label',node.get('name',ident)) + if not isinstance(label,str) or not label.strip(): raise ValueError('Graphify returned an empty/nontext node label.') + ids.add(ident) + for edge in edges: + if not isinstance(edge,dict) or not isinstance(edge.get('source'),(str,int)) or not isinstance(edge.get('target'),(str,int)): + raise ValueError('Graphify returned an invalid edge.') + if str(edge['source']) not in ids or str(edge['target']) not in ids: + raise ValueError('Graphify returned a dangling edge; no partial graph was installed.') + return raw + + +def load_graph(path: Path, workspace: Path): + if not path.is_file(): raise ValueError('Graphify finished without graph.json; the previous graph was preserved.') + if path.is_symlink() or workspace.resolve() not in path.resolve().parents: + raise ValueError('Graphify output escaped its isolated workspace.') + if path.stat().st_size>MAX_GRAPH_BYTES: raise ValueError('Graphify output exceeds 20 MB.') + with path.open('rb') as stream: data=stream.read(MAX_GRAPH_BYTES+1) + if len(data)>MAX_GRAPH_BYTES: raise ValueError('Graphify output exceeds 20 MB.') + def invalid(_): raise ValueError('Graphify output contains a non-finite number.') + try: raw=json.loads(data.decode('utf-8-sig'),parse_constant=invalid) + except (UnicodeError,json.JSONDecodeError,RecursionError) as exc: + raise ValueError('Graphify returned malformed graph JSON; the previous graph was preserved.') from exc + return validate_graph(raw) + + +def source_refs(item, files, input_root): + values=[] + if isinstance(item.get('source_file'),str): values.append(item['source_file']) + if isinstance(item.get('source_files'),list): values += [s for s in item['source_files'] if isinstance(s,str)] + result=[] + for value in values: + try: + p=Path(value.replace('\\','/')) + resolved=(input_root/p).resolve() if not p.is_absolute() else p.resolve() + if resolved.parent==input_root.resolve() and resolved.name in files: + result.append(files[resolved.name].id) + except (ValueError,OSError): pass + return list(dict.fromkeys(result)) + + +def attach_provenance(graph,raw,files,input_root): + sources={s.id:s for s in files.values()} + def apply(target,item,extra_ids=()): + target.source_ids=list(dict.fromkeys(source_refs(item,files,input_root)+list(extra_ids))) + # File attribution alone is not a quote or evidence of entailment. + candidates=item.get('evidence',[]) + if isinstance(candidates,str): candidates=[{'quote':candidates}] + if not isinstance(candidates,list): candidates=[] + if isinstance(item.get('quote'),str): candidates=[*candidates,{'quote':item['quote']}] + for candidate in candidates[:10]: + quote=candidate.get('quote','') if isinstance(candidate,dict) else '' + if not isinstance(quote,str) or not quote.strip() or len(quote)>3000: continue + for sid in target.source_ids: + s=sources[sid];pos=s.text.find(quote) + if pos>=0: + target.evidence.append(Evidence(source_id=sid,quote=quote,start=pos,end=pos+len(quote),verified=True)) + break + # Graphify's EXTRACTED/INFERRED labels remain external interpretations; + # even exact attribution does not establish the truth of an edge. + target.status='imported' + nodes={n.id:n for n in graph.nodes} + for item in raw['nodes']: apply(nodes[str(item['id'])],item) + edge_lookup={} + for e in graph.edges: edge_lookup.setdefault((e.source,e.target),[]).append(e) + for item in raw.get('edges',raw.get('links',[])): + a,b=str(item['source']),str(item['target']) + if a==b: continue + match=edge_lookup.get((a,b),[]) + if match: + target=match.pop(0) + apply(target,item,[*nodes[a].source_ids,*nodes[b].source_ids]) -def run_graphify(project: Project, clients: Clients, job): - if clients.settings.provider == "codex": - raise ValueError("Use Native extraction or import a Graphify JSON graph with Codex sign-in. The optional external Graphify process requires its own API credentials.") - executable = clients.settings.graphify_executable or shutil.which("graphify") - if not executable or not Path(executable).is_file(): - raise ValueError("Graphify is not installed or its executable was not found. Select Native in Connections, import graph.json, or install graphifyy and choose its executable.") - if clients.settings.provider == "anthropic": - backend = "claude" - envkey, basekey, modelkey = "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL" - else: - backend = "openai" - envkey, basekey, modelkey = "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL" - clients.check(clients.settings.base_url) - if not clients.llm_key() and not clients.settings.base_url.startswith(("http://localhost", "http://127.0.0.1")): - raise ProviderError("Configure a writing API key before running Graphify.") - with tempfile.TemporaryDirectory(prefix="graphpaper-") as folder: - root = Path(folder) - for s in project.sources: - if s.enabled and s.role != "voice": - (root / (s.id + ".md")).write_text(s.title + "\n\n" + s.text, encoding="utf-8") - env = os.environ.copy() - env.update({envkey: clients.llm_key() or "local", basekey: clients.settings.base_url, - modelkey: clients.settings.extraction_model or clients.settings.model, - "GRAPHIFY_NO_AUTO_REFRESH": "1", "PYTHONIOENCODING": "utf-8"}) - args = [str(executable), "extract", str(root), "--backend", backend, "--mode", "deep", "--no-viz", "--no-cluster", "--token-budget", "4000", "--max-concurrency", "2"] - job.note("Running Graphify. Its own API usage is not metered by GraphPaper; check your provider billing.") - flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 - with (root / "process.log").open("w", encoding="utf-8") as log: - proc = subprocess.Popen(args, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, creationflags=flags) - started = time.monotonic() - try: +def run_graphify(project,clients,job): + # Consent is checked against the actual selected provider, not a stale API + # base URL when the user has selected Codex OAuth. + if clients.settings.provider=='codex': clients.check('https://chatgpt.com') + else: clients.check(clients.settings.base_url) + runtime=check_runtime(clients.settings.graphify_executable) + if not runtime['available']: raise ValueError(runtime['error']) + sources=[s for s in project.sources if s.enabled and s.role!='voice'] + if not sources: raise ValueError('No enabled non-voice sources are available for Graphify.') + if sum(len(s.text.encode('utf-8')) for s in sources)>64_000_000: + raise ValueError('Graphify input exceeds the 64 MB text limit. Split the project; no sources were truncated.') + scope=JobScope(job,clients.settings.graphify_timeout_seconds) + routed=copy.copy(clients);routed.job=scope + # Validate selected effort through the real provider's existing model route. + # Native completions will check again immediately before a billed request. + if clients.settings.provider=='codex': + from .codex import get_codex + from .reasoning import validate_effort + codex=get_codex(clients.vault.path.parent,clients.settings.codex_executable) + if not codex.status()['signed_in']: raise ProviderError('Sign in with ChatGPT before running external Graphify with Codex.') + model=clients.settings.extraction_model or clients.settings.model + catalog=codex.models() + info=next((m for m in catalog if m['id']==model),None) if model else next((m for m in catalog if m.get('is_default')),None) + if model and info is None: raise ProviderError('The extraction model was not found in the Codex catalog. Refresh models in Connections.') + validate_effort(clients.settings.extraction_reasoning_effort,info,'codex') + with tempfile.TemporaryDirectory(prefix='graphpaper-graphify-') as temp: + root=Path(temp);input_root=root/'input';input_root.mkdir();output=root/'output' + files={} + for i,s in enumerate(sources,1): + filename=f'source-{i:04d}.md';files[filename]=s + (input_root/filename).write_text(f'# {s.title}\n\nSource ID: {s.id}\nRole: {s.role}\n\n{s.text}\n',encoding='utf-8',newline='\n') + with Gateway(routed,scope) as gateway: + env=isolated_environment(root/'home') + env.update({'OPENAI_API_KEY':gateway.token,'OPENAI_BASE_URL':gateway.url,'OPENAI_MODEL':gateway.model, + 'GRAPHIFY_OPENAI_MODEL':gateway.model,'GRAPHIFY_OUT':str(output), + 'GRAPHIFY_MAX_OUTPUT_TOKENS':str(clients.settings.max_output_tokens), + 'GRAPHIFY_API_TIMEOUT':str(clients.settings.request_timeout_seconds+10)}) + args=[*runtime['command'],'extract',str(input_root),'--backend','openai','--model',gateway.model, + '--mode','deep','--no-viz','--no-cluster','--out',str(output), + '--token-budget',str(clients.settings.graphify_chunk_tokens),'--max-concurrency','1','--max-workers','1', + '--api-timeout',str(clients.settings.request_timeout_seconds+10)] + scope.note(f'Running public Graphify {PINNED_VERSION} through {clients.settings.provider}; using the extraction model and reasoning setting. Requests count toward this job budget.',5) + # External stdout is not an authority or a place to return secrets. + # It is consumed and discarded to avoid blocking or unbounded logs. + with OwnedProcess(args,cwd=root,env=env,stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) as proc: while proc.poll() is None: - job.check() - if time.monotonic() - started > 1200: - raise ValueError("Graphify exceeded its 20-minute timeout.") - time.sleep(0.25) + scope.check() + if gateway.error: raise gateway.error + time.sleep(.1) + scope.check() + if gateway.error: raise gateway.error if proc.returncode: - raise ValueError("Graphify failed. Native extraction remains available; no old graph was replaced.") - finally: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - candidates = [root / "graphify-out/graph.json", root / "graph.json"] - graphpath = next((p for p in candidates if p.exists()), None) - if not graphpath: - raise ValueError("Graphify finished without graph.json. Check the installed version; select Native or import a graph manually.") - if graphpath.stat().st_size > 20_000_000: - raise ValueError("Graphify output exceeds 20 MB.") - graph = import_graph(json.loads(graphpath.read_text(encoding="utf-8"))) - graph.engine = "Graphify CLI + GraphPaper evidence extraction" - return graph + raise ValueError('Graphify failed before producing a complete graph. Verify the pinned runtime and selected model; no old graph was replaced and no Native fallback ran.') + if not gateway.completed: raise ValueError('Graphify made no successful model requests. The empty/unverified extraction was not installed.') + raw=load_graph(output/'graph.json',root) + graph=import_graph(raw) + attach_provenance(graph,raw,files,input_root) + graph.engine=f'Graphify {PINNED_VERSION} via {clients.settings.provider}' + graph.built=now() + graph.coverage={'engine':'external_graphify','runtime_version':runtime['version'],'runtime_selection':runtime['selection'], + 'sources':len(sources),'characters':sum(len(s.text) for s in sources), + 'source_ids':[s.id for s in sources], + 'model':clients.settings.extraction_model or clients.settings.model or 'provider default', + 'reasoning_effort':clients.settings.extraction_reasoning_effort, + 'model_requests':gateway.completed,'requests':list(gateway.receipts), + 'scope':'Enabled source text exported in full; Graphify controls chunking and extraction. No extra Native extraction pass was run.'} + graph.warnings=['Graphify relationships are externally generated interpretations. Source-file links and exact quotation matches establish attribution, not truth.'] + graph.warnings.extend(dict.fromkeys(w for source in sources for w in source.warnings)) + graph.warnings.extend(dict.fromkeys(w for s in sources for w in s.warnings)) + if raw.get('hyperedges'): + graph.coverage['hyperedge_count']=len(raw['hyperedges']) + graph.warnings.append('This view displays pairwise edges. Graphify hyperedges are not displayed as independent hyperedge objects.') + return graph diff --git a/graphpaper/graphify_gateway.py b/graphpaper/graphify_gateway.py new file mode 100644 index 0000000..0326132 --- /dev/null +++ b/graphpaper/graphify_gateway.py @@ -0,0 +1,168 @@ +"""Job-scoped loopback OpenAI-compatible bridge to the configured GraphPaper provider. + +Only Graphify's two-message text extraction is accepted. No tools, arbitrary +models, forwarding destinations, browser origins or public listener are exposed. +""" +from __future__ import annotations +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import hmac +import json +import secrets +import threading +import time +from .providers import Cancelled, ProviderError + +MAX_REQUEST_BYTES = 6_000_000 + + +class JobScope: + """Deadline/cancellation and frozen accounting for a single external run.""" + def __init__(self, parent, seconds): + self.parent=parent; self.deadline=time.monotonic()+seconds + self.stop=threading.Event(); self.closed=False + self.lock=threading.RLock() + + @property + def cancel(self): return self.parent.cancel + @property + def receipts(self): return self.parent.receipts + @property + def usage(self): return self.parent.usage + def check(self): + self.parent.check() + if self.stop.is_set(): raise Cancelled('Graphify request stopped.') + if time.monotonic()>=self.deadline: raise ProviderError('Graphify exceeded the configured extraction timeout.') + def note(self,*a,**k): + with self.lock: + if not self.closed: + self.check(); self.parent.note(*a,**k) + def before_call(self,*a,**k): + with self.lock: + self.check(); self.parent.before_call(*a,**k) + def record_usage(self,*a,**k): + with self.lock: + # After cancellation, a non-interruptible API response can still be + # billed. Never mutate an already-persisted project's accounting. + if not self.closed: self.parent.record_usage(*a,**k) + def close(self): + with self.lock: + self.closed=True; self.stop.set() + + +class Gateway: + def __init__(self, clients, scope): + self.clients=clients; self.scope=scope + self.token='gp-graphify-'+secrets.token_urlsafe(40) + self.model='graphpaper-extraction' # a fixed local alias, never a provider selector + self.lock=threading.Lock() + self.error=None; self.completed=0; self.receipts=[] + self.active=threading.Event() + outer=self + class Handler(BaseHTTPRequestHandler): + protocol_version='HTTP/1.0' + def log_message(self,*args): pass + def setup(self): + super().setup(); self.connection.settimeout(5) + def send_json(self,status,value): + data=json.dumps(value,ensure_ascii=False,allow_nan=False).encode() + try: + self.send_response(status); self.send_header('Content-Type','application/json') + self.send_header('Content-Length',str(len(data))); self.send_header('Cache-Control','no-store') + self.end_headers(); self.wfile.write(data) + except (BrokenPipeError,ConnectionResetError,TimeoutError): pass + def allowed(self): + host=f'127.0.0.1:{self.server.server_port}' + if self.headers.get('Host')!=host or self.headers.get('Origin') or self.headers.get('Transfer-Encoding'): + self.send_json(403,{'error':{'message':'Graphify local request refused.'}}); return False + expected='Bearer '+outer.token + if not hmac.compare_digest(self.headers.get('Authorization',''),expected): + self.send_json(401,{'error':{'message':'Graphify job authorization required.'}}); return False + return True + def do_GET(self): + if not self.allowed(): return + if self.path=='/v1/models': + self.send_json(200,{'object':'list','data':[{'id':outer.model,'object':'model','owned_by':'graphpaper'}]}) + else: self.send_json(404,{'error':{'message':'No such gateway operation.'}}) + def do_POST(self): + if not self.allowed(): return + if self.path!='/v1/chat/completions': + self.send_json(404,{'error':{'message':'No such gateway operation.'}}); return + try: + size=int(self.headers.get('Content-Length','0')) + if size<1 or size>MAX_REQUEST_BYTES: raise ValueError('Invalid request length.') + data=self.rfile.read(size) + if len(data)!=size: raise ValueError('Incomplete request.') + raw=json.loads(data,parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Non-finite input.'))) + system,user=outer.parse(raw) + except (ValueError,TypeError,TimeoutError): + self.send_json(400,{'error':{'message':'Graphify must send bounded, non-streamed text extraction messages.'}}); return + if not outer.lock.acquire(blocking=False): + self.send_json(409,{'error':{'message':'One Graphify inference request at a time.'}}); return + try: + outer.scope.check() + if outer.error: raise ProviderError('An earlier extraction request failed; no retry was attempted.') + outer.active.set() + outer.scope.note(f'Graphify: extracting document relationships (model call {outer.completed+1}).') + before=dict(outer.scope.usage) + value=outer.clients.complete(system,user,role='extraction',json_mode=True, + max_tokens=outer.clients.settings.max_output_tokens) + outer.scope.check() + if not isinstance(value,dict) or not isinstance(value.get('nodes'),list) or not isinstance(value.get('edges'),list): + raise ProviderError('The extraction model returned malformed graph JSON; the previous graph was preserved.') + content=json.dumps(value,ensure_ascii=False,allow_nan=False) + if len(content.encode())>6_000_000: raise ProviderError('The extraction graph response exceeded its limit.') + outer.completed+=1 + after=outer.scope.usage + usage={'prompt_tokens':max(0,after.get('input_tokens',0)-before.get('input_tokens',0)), + 'completion_tokens':max(0,after.get('output_tokens',0)-before.get('output_tokens',0))} + usage['total_tokens']=sum(usage.values()) + outer.receipts.append({'call':outer.completed,'model':outer.clients.settings.extraction_model or outer.clients.settings.model or 'provider default', + 'provider':outer.clients.settings.provider,'reasoning_effort':outer.clients.settings.extraction_reasoning_effort,**usage}) + self.send_json(200,{'id':'graphpaper-'+str(outer.completed),'object':'chat.completion','created':int(time.time()), + 'model':outer.model,'choices':[{'index':0,'message':{'role':'assistant','content':content},'finish_reason':'stop'}],'usage':usage}) + except (ValueError,ProviderError,Cancelled) as exc: + outer.error=exc + self.send_json(400,{'error':{'message':'GraphPaper extraction failed or stopped; see its job status.'}}) + except Exception: + outer.error=ProviderError('The Graphify model bridge failed; the previous graph was preserved.') + self.send_json(500,{'error':{'message':'GraphPaper extraction bridge failed.'}}) + finally: + outer.active.clear(); outer.lock.release() + self.server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + self.server.daemon_threads=True; self.server.block_on_close=False + self.thread=threading.Thread(target=self.server.serve_forever,kwargs={'poll_interval':.1},name='graphpaper-graphify-gateway',daemon=True) + self.thread.start() + + @property + def url(self): return f'http://127.0.0.1:{self.server.server_port}/v1' + + def parse(self,raw): + if not isinstance(raw,dict) or raw.get('model')!=self.model or raw.get('stream') or raw.get('tools') or raw.get('functions'): + raise ValueError('Unsupported request.') + messages=raw.get('messages') + if not isinstance(messages,list) or len(messages)!=2 or [m.get('role') for m in messages if isinstance(m,dict)]!=['system','user']: + raise ValueError('Expected extraction messages.') + def content(m): + value=m.get('content') + if isinstance(value,list): + if not all(isinstance(v,dict) and v.get('type')=='text' and isinstance(v.get('text'),str) for v in value): + raise ValueError('Only text is allowed.') + value='\n'.join(v['text'] for v in value) + if not isinstance(value,str) or not value.strip(): raise ValueError('Expected text.') + return value + system,user=map(content,messages) + if len(system)+len(user)>self.clients.settings.context_chars: raise ValueError('Context limit.') + return system,user + + def close(self): + self.scope.stop.set() + self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2) + # Codex checks cancellation while awaiting the child. An in-flight API + # HTTP request may take longer and is explicitly not advertised as free. + deadline=time.monotonic()+6 + while self.active.is_set() and time.monotonic() dict[str,str]: + home.mkdir(parents=True,exist_ok=True) + # Provider keys, OAuth tokens, proxy variables, user hooks, Python path + # overrides and Graphify custom-provider config do not cross this boundary. + names={'systemroot','windir','systemdrive','comspec','path','pathext','processor_architecture','number_of_processors','lang','lc_all','ld_library_path','dyld_library_path'} + env={k:v for k,v in os.environ.items() if k.lower() in names} + for name in ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_CACHE_HOME','TEMP','TMP','TMPDIR']: + env[name]=str(home) + env.update({'PYTHONIOENCODING':'utf-8','PYTHONUTF8':'1','PYTHONNOUSERSITE':'1', + 'GRAPHIFY_NO_AUTO_REFRESH':'1','GRAPHIFY_QUERY_LOG_DISABLE':'1','GRAPHIFY_GOOGLE_WORKSPACE':'0', + 'GRAPHIFY_MAX_RETRIES':'0','GRAPHIFY_MAX_RETRY_DEPTH':'0','GRAPHIFY_MAX_WORKERS':'1', + 'GRAPHIFY_LLM_TEMPERATURE':'none','NO_PROXY':'127.0.0.1,localhost','no_proxy':'127.0.0.1,localhost'}) + package_root=Path(getattr(sys,'_MEIPASS',Path(__file__).resolve().parent.parent)) + cache=package_root/'vendor/tiktoken' + if cache.is_dir(): env['TIKTOKEN_CACHE_DIR']=str(cache) + return env + + +def check_runtime(configured='') -> dict: + if configured: + path=Path(configured).expanduser() + if not path.is_file() or path.suffix.lower() in {'.bat','.cmd','.ps1','.sh'}: + return {'available':False,'selection':'custom','error':'The configured Graphify path is not a native executable. Clear it to use the bundled runtime.', 'requirement':REQUIREMENT} + command=[str(path.resolve())] + with tempfile.TemporaryDirectory(prefix='graphpaper-graphify-probe-') as temp: + env=isolated_environment(Path(temp)/'home') + try: + with OwnedProcess([*command,'--version'],cwd=temp,env=env,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE) as proc: + out,err=proc.communicate(timeout=15) + version=re.search(r'\b(\d+\.\d+\.\d+)\b',out.decode('utf-8',errors='replace')[:2000]) + found=version[1] if version else '' + available=proc.returncode==0 and found==PINNED_VERSION + return {'available':available,'selection':'custom','command':command,'version':found,'requirement':REQUIREMENT, + 'error':'' if available else f'This adapter is verified with Graphify {PINNED_VERSION}. Clear the custom path to use the bundled runtime, or install {REQUIREMENT} in a separate environment.'} + except (OSError,RuntimeError,subprocess.TimeoutExpired): + return {'available':False,'selection':'custom','requirement':REQUIREMENT,'error':'The configured Graphify executable did not pass its bounded version check. Clear its path to use the bundled runtime.'} + command=worker_command() + with tempfile.TemporaryDirectory(prefix='graphpaper-graphify-probe-') as temp: + root=Path(temp);env=isolated_environment(root/'home');report=root/'probe.json' + env['GRAPHPAPER_GRAPHIFY_PROBE_FILE']=str(report) + try: + with OwnedProcess([*command,'--graphpaper-probe'],cwd=temp,env=env,stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) as proc: + proc.wait(timeout=20) + result=json.loads(report.read_text(encoding='utf-8')) if report.is_file() and report.stat().st_size<50000 else {} + return {**result,'available':bool(result.get('available') and proc.returncode==0), + 'selection':'bundled' if getattr(sys,'frozen',False) or '--graphify-worker' in command else 'python-environment', + 'command':command,'requirement':REQUIREMENT, + 'error':result.get('error') or ('' if result.get('available') else 'Graphify runtime is missing. Use the complete Windows release or install requirements-graphify.txt in the source environment.')} + except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired): + return {'available':False,'selection':'bundled' if getattr(sys,'frozen',False) or '--graphify-worker' in command else 'python-environment','requirement':REQUIREMENT, + 'error':'Graphify could not start. Use the complete release or install requirements-graphify.txt; Native extraction remains separately available.'} diff --git a/graphpaper/graphify_worker.py b/graphpaper/graphify_worker.py new file mode 100644 index 0000000..aea4c69 --- /dev/null +++ b/graphpaper/graphify_worker.py @@ -0,0 +1,62 @@ +"""Launch the unmodified, public Graphify CLI in its own process. + +Packaged entry: GraphPaper.exe --graphify-worker ... +Source entry: python ... +This is not a second implementation of graph extraction or a private fork. +""" +from __future__ import annotations +import importlib.metadata +import json +import os +from pathlib import Path +import sys + +PINNED_VERSION = '0.9.80' + + +def probe() -> dict: + # Do not import graphify.llm for a probe: its tokenizer can initialize a + # download. Availability checks must not spend tokens or fetch model data. + required = ['graphifyy','openai','tiktoken','networkx','numpy','rapidfuzz','tree-sitter'] + versions={}; missing=[] + for name in required: + try: versions[name]=importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: missing.append(name) + valid = not missing and versions.get('graphifyy') == PINNED_VERSION + return {'available':valid,'package':'graphifyy','version':versions.get('graphifyy',''), + 'required_version':PINNED_VERSION,'dependencies':versions,'missing':missing, + 'distribution':'public PyPI, unmodified Graphify CLI', + 'error': '' if valid else 'Install the pinned graphifyy[openai] dependency, or use the complete Windows release.'} + + +def main(argv=None): + args=list(sys.argv[1:] if argv is None else argv) + if sys.stdout is None: sys.stdout=open(os.devnull,'w',encoding='utf-8') + if sys.stderr is None: sys.stderr=open(os.devnull,'w',encoding='utf-8') + if args==['--graphpaper-probe']: + result=probe() + report=os.environ.get('GRAPHPAPER_GRAPHIFY_PROBE_FILE') + if report: Path(report).write_text(json.dumps(result),encoding='utf-8') + print(json.dumps(result),flush=True) + return 0 if result['available'] else 2 + if not args or args[0]!='extract': + raise SystemExit('The GraphPaper worker exposes Graphify extraction only; use a separate Graphify installation for other CLI actions.') + status=probe() + if not status['available']: + print(json.dumps(status),file=sys.stderr); return 2 + # The worker only accepts an adapter-created loopback gateway. No provider + # credentials are forwarded to this process. + from urllib.parse import urlsplit + base=urlsplit(os.environ.get('OPENAI_BASE_URL','')) + if base.scheme!='http' or base.hostname!='127.0.0.1' or not base.port or base.path!='/v1' or not os.environ.get('OPENAI_API_KEY','').startswith('gp-graphify-'): + raise SystemExit('Graphify worker requires its temporary GraphPaper gateway.') + # Let the publicly released CLI parse its own documented flags and build its + # own graph. sys.argv is necessary because upstream dispatch uses it. + sys.argv=['graphify',*args] + from graphify.__main__ import main as upstream_main + value=upstream_main() + return value if isinstance(value,int) else 0 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/graphpaper/models.py b/graphpaper/models.py index c725ed3..6aee2d9 100644 --- a/graphpaper/models.py +++ b/graphpaper/models.py @@ -69,9 +69,11 @@ class Node(Model): evidence: list[Evidence] = Field(default_factory=list) status: Literal["sourced", "inferred", "canon", "proposed", "imported"] = "inferred" community: int = 0 + source_ids: list[str] = Field(default_factory=list) class Edge(Model): + source_ids: list[str] = Field(default_factory=list) id: str = Field(default_factory=lambda: uid("e_")) source: str target: str @@ -196,6 +198,8 @@ class Settings(Model): jev_model: str = "jev-latest" graph_engine: Literal["native", "graphify"] = "native" graphify_executable: str = "" + graphify_timeout_seconds: int = Field(1200, ge=10, le=7200) + graphify_chunk_tokens: int = Field(4000, ge=500, le=16000) max_calls: int = Field(80, ge=5, le=500) max_candidates: int = Field(30, ge=6, le=80) refine: bool = True diff --git a/graphpaper/pipeline.py b/graphpaper/pipeline.py index 2a251aa..cec2f70 100644 --- a/graphpaper/pipeline.py +++ b/graphpaper/pipeline.py @@ -91,14 +91,17 @@ def build_graph(p: Project, clients: Clients, store: Store, job: Job): else: raise ValueError("Add a source first, or give your fiction project a premise in Direction.") parts = [(s, a, b, t) for s in sources for a, b, t in chunks(s.text)] - if len(parts) > 350: + if clients.settings.graph_engine != "graphify" and len(parts) > 350: raise ValueError(f"This library requires {len(parts)} extraction chunks. Split it into projects or import an existing graph.") graphs = [] - extra = None if clients.settings.graph_engine == "graphify": from .graphify_adapter import run_graphify - extra = run_graphify(p, clients, job) - graphs.append(extra) + result = run_graphify(p, clients, job) + job.check() + p.graph = result + p.angles, p.selected_angle = [], "" + job.note(f"External Graphify complete: {len(result.nodes)} nodes and {len(result.edges)} edges. No duplicate Native extraction pass.", 95) + return for i, (source, a, b, text) in enumerate(parts): job.note(f"Reading {source.title} · passage {i + 1} of {len(parts)}", 5 + int(80 * i / len(parts))) cachekey = digest("extract-v1|" + source.id + "|" + source.digest + "|" + source.role + "|" + p.mode + "|" + (clients.settings.extraction_model or clients.settings.model) + "|" + clients.settings.base_url + "|" + str(a)) @@ -122,16 +125,13 @@ def build_graph(p: Project, clients: Clients, store: Store, job: Job): bad_quotes = sum(not ev.verified for n in g.nodes for ev in n.evidence) + sum(not ev.verified for e in g.edges for ev in e.evidence) if bad_quotes: g.warnings.append(f"{bad_quotes} extracted quote anchors did not exactly match the text. These are flagged, not accepted as evidence.") - if extra: - g.engine = extra.engine - g.warnings.extend(extra.warnings) p.graph = g p.angles, p.selected_angle = [], "" job.note(f"Graph ready: {len(g.nodes)} concepts and {len(g.edges)} relationships. All {len(parts)} text passages processed.", 95) def compact_motif(c): - return {"id": c["id"], "motif": c["motif"], "nodes": [{"id": n["id"], "label": n["label"], "description": n["description"][:500]} for n in c["nodes"]], "edges": [{"relation": e["relation"], "description": e["description"][:350], "status": e["status"], "evidence": [{"source_id": v["source_id"], "quote": v["quote"][:400]} for v in e["evidence"] if v["verified"]][:3]} for e in c["edges"]], "source_ids": c["source_ids"]} + return {"id": c["id"], "motif": c["motif"], "nodes": [{"id": n["id"], "label": n["label"], "description": n["description"][:500]} for n in c["nodes"]], "edges": [{"relation": e["relation"], "description": e["description"][:350], "status": e["status"], "evidence": [{"source_id": v["source_id"], "quote": v["quote"][:400]} for v in e["evidence"] if v["verified"]][:3]} for e in c["edges"]], "source_ids": c["source_ids"], "source_context": c.get("source_context", [])} def make_angles(p: Project, clients: Clients, job: Job): @@ -147,6 +147,21 @@ def make_angles(p: Project, clients: Clients, job: Job): candidates.append({"id": "m_" + n.id, "motif": "single concept", "nodes": [n.model_dump()], "edges": [], "node_ids": [n.id], "edge_ids": [], "source_ids": sorted({e.source_id for e in n.evidence if e.verified}), "structural_rank": 0}) if not candidates: raise ValueError("No concepts remain after exclusions.") + if p.graph.coverage.get('engine') == 'external_graphify': + # File provenance is a retrieval pointer, not verified entailment. Give + # the evaluator bounded original passages rather than trusting a label. + source_index = {s.id:s for s in p.sources if s.enabled and s.role != 'voice'} + for c in candidates: + c['source_ids'] = sorted(set(c['source_ids']) | {sid for n in c['nodes'] for sid in n.get('source_ids',[]) if sid in source_index}) + c['source_context'] = [] + for sid in c['source_ids'][:3]: + source = source_index.get(sid) + if source is None: continue + positions = [source.text.lower().find(n['label'].lower()) for n in c['nodes'] if len(n['label']) > 2] + found = [v for v in positions if v >= 0] + start = max(0, min(found)-180) if found else 0 + c['source_context'].append({'source_id':sid,'role':source.role,'text':source.text[start:start+1000], + 'scope':'Retrieved source excerpt, not proof that the proposed relationship follows.'}) job.note(f"Discovered {len(candidates)} diverse paths and motifs.", 10) for offset in range(0, len(candidates), 4): batch = candidates[offset:offset + 4] diff --git a/graphpaper/server.py b/graphpaper/server.py index 51b0b92..53d76ab 100644 --- a/graphpaper/server.py +++ b/graphpaper/server.py @@ -124,6 +124,12 @@ async def put_settings(request: Request): store.set_settings(value.model_dump()) return public_settings() + @app.get("/api/graphify/status") + async def graphify_status(): + from .graphify_runtime import check_runtime + result = await run_in_threadpool(check_runtime, settings().graphify_executable) + return {k:v for k,v in result.items() if k != "command"} + @app.get("/api/models") async def get_models(): return await run_in_threadpool(Clients(settings(), vault).discover_models) diff --git a/publish-manifest.json b/publish-manifest.json index 136176d..d5eca08 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -5,41 +5,43 @@ ".github/topics.json": "341aa983b1829624dec7ab7cd2e83eed08d45bc78305033c1e860631c1227e41", ".github/workflows/docs.yml": "19c4d6229a804adf09358171b19a139b45f1c2630b751213af1e9e62baed2c40", ".github/workflows/import-build.yml": "c56a990b97242f154907f76059f5b859448ca8de7b582928d63bb3717ac0d60a", - ".github/workflows/quality.yml": "caef75e39f0db695106486877c4ad47c0db996cf7442167101a979626ac6eb88", - ".github/workflows/windows.yml": "25e5711257bfe3f8824fe4f4dfee9fdb6231bef7b3a90aef6ec0ffd1ec1047da", + ".github/workflows/quality.yml": "6e734a688ba978a787e16581b78547d150f010969626d842c1518fa1b7a9e303", + ".github/workflows/windows.yml": "3f4e05488fd93b62d3e85cea90f050ce11683d8438a968694a7b1bfa0c90ce9b", ".gitignore": "229a84e2b07c91b31c30f06df236ea17fe211eb75b217adc2eecb3f9d377f9bf", - "CHANGELOG.md": "d6a2299d607e9ce595ae88831217353460cacb56e5240a3dc3fbca97ec26f73e", + "CHANGELOG.md": "be573710ae025a368a9e46f92096a88132f71af11883cea318fdf5a4cc854ef5", "CONTRIBUTING.md": "170e7cd71886a6cd3aa82c3ab5a478cde152faa4f243900e047bca565e658001", - "GraphPaper.pyw": "e862a9309815483e4c166396da8eccd304c118461f6b220246dd18c23bcc9bb4", + "GraphPaper.pyw": "8fd19e9ba8caa25b11b0b458e00aa3ea4067ca5d90161f7ba2781f1d514fa094", "LICENSE": "5849844cbc9e9e199cf576224279d63d5fa56ca913f7a13285e4aa4fcb99bf80", "Open GraphPaper.vbs": "6ddf70eba29c281f9373c11b6aa7f69862c8ea7b03050b974c98d817ca11974e", "Publish GraphPaper.pyw": "a55984668dd0040d9bb5fc8c4c18ea5a201eede7e8a51ab79f8f0f095162ec8b", "Publish GraphPaper.vbs": "f9c595bcd13da5df60dcc2cfd906665243628f6e6c10a8b6a03ad1392a9698ad", - "README.md": "303744c35423fe6e59a90c5c19783c395e67764ab741dfe93908b806e17aa3e1", + "README.md": "14b6937ed5b70455002b245427e6545007cf14eb572aac18e28649c316e620b3", "SECURITY.md": "e70e5b87ea06cdc075c3111a1eb0bb6ac5f5b6f8b55f5e735ad69d84ae63cda6", - "docs/ARCHITECTURE.md": "6c699426755ae848b43d081e166879c3dc99e5a92bcb5812db43349dc636df29", - "docs/CONNECTIONS.md": "feaeea0691e176b9fe01077833f1f77c8c23de081e735fe58cd4e0d422863ccc", - "docs/DEVELOPMENT.md": "edaa1842a60a53d6ebd9119282757e3fd015fcb5e7693f237783d26919e8dcd3", + "docs/ARCHITECTURE.md": "86a28806513cf8b533d6a4767322c7dd339a6a2ed112081ea7ce5fcdf4ffd89d", + "docs/CONNECTIONS.md": "eec1286ddc356bbb2cd7d63692e73abae6d0f7e9159bbb2edee6377dd5b3fd72", + "docs/DEVELOPMENT.md": "2c21f35be868e9da63d90cbc78b00488837916dbe36fdd631127c4035583ed21", "docs/EXPORTS-AND-APA.md": "73e60e653118d2ee55f2f3e04b5d2aeac427c40dd33c76e1d050337261862d52", "docs/FICTION.md": "6ac2f136e848db8b9fd2bd329d4228894ad765e95d6a9cec317bb9372a847df8", "docs/GETTING-STARTED.md": "d663e4b1ea461de7e87ba8bb2fa15949db7ba8d89de69cbf1c0d1a265d3e2abd", - "docs/GRAPH-AND-JEV.md": "083f758ca2ad2094d1c0719c9827ff04e1a7471c794feb8de91950a3a5ad7fd0", - "docs/INTEGRATIONS.md": "8d8b403f18b4d9b3292f47c46018a39ef26a63788784fb4d767990e3729cb859", + "docs/GRAPH-AND-JEV.md": "fe70350f7390692bf1d096888ad8a4545927840791b22a991bd41bae8313ca65", + "docs/INTEGRATIONS.md": "3fe70965f7597f7746335278ae954fed891264e6d767fd5a046cf85eb1baf3d2", "docs/NONFICTION.md": "d4bedf2e435dd549b18663151cc85104bb534420ac109600a3106b9670a9669a", "docs/PROJECTS-AND-SOURCES.md": "828179218c31bb9f50698e2d047a04b4a056b979800795a37cd790d29060e2dd", "docs/PUBLISHING.md": "324b83d5138b22bb29ca1737cbc0563595cc14f03177d478c478bde9109f975f", "docs/QUALITY.md": "906170a9b6c71c1381dd2b9c0f5cc02e3cd52d8bfc2a574df578a730968c19c2", - "docs/README.md": "f7203942cd76d3471e2d7dab9774c59a8afef0edde95195b74ca2efdd82f23a0", + "docs/README.md": "a03d4fff2f1a5e66c84aa9af499796367d44ff2d3b946e5fc4601ec519b237c4", "docs/RELEASE-0.2.1.md": "42642432ff5c6017630d33a64726c5dca37abbbb36a476a901a59ec8887aa870", "docs/RELEASE-0.2.md": "c03a41903088590845d0da0707a562b9e6cbf4c5e1ff57d8794763f349093e44", "docs/RELEASE-0.3.0.md": "e32f30a3a9716c310be21dcf540cf9060384b0c7fdf69c8fc1c45d36c105cf29", + "docs/RELEASE-0.3.1.md": "d95db299580c5672a9814436192035d99212dae3b3ae5bb15fc41723067a2966", "docs/SCIENCE.md": "9357a2f4ac714b6330aff4ccfe45ca0457d18e7816a73646314024823fd0ea00", - "docs/SECURITY.md": "7484d247ee781d793c7e2db6071f2778b715102eb4f825768f8de5ed37b6beb1", + "docs/SECURITY.md": "0f818eb0cf4e8f94ffaaeb6da3cc2c2bc474674b673d185c61b86b2eb5fd3864", "docs/TROUBLESHOOTING.md": "a71223c4cff8c330e2a2170ce329c3cc3501baf829e3153f7503036b13be5b22", "docs/UPDATE-0.2.md": "cd71d4bb37bfdbb859fa4ea558a91e0cb61f900f0a4390d87f513abb8b8d815d", + "docs/UPDATE-0.3.1.md": "a793660a63ca9418ae566149d841075a7d78a91554890d5d8db4ba316c8398b5", "docs/UPDATE-0.3.md": "99076ec9ea12986d79b93ec25a729c90403d3432d8e51598db5903b862fba459", "docs/VOICE-AND-PROSE.md": "b70fa33b57ba010a8de329e952c862c440370e66a68df02800f8203fe467a4d5", - "docs/WINDOWS.md": "768e42f3619bc25810ec60403a2bd966283b3e5cdea0df47a9170b2e757b1f02", + "docs/WINDOWS.md": "8b7ed8cbe01cf69d0dbc16967fa31cbce78ed28bb4475eb27eff7535737847ca", "docs/images/angles.webp": "1f9a0b5b46c9ae7e84ed1f96ea909d8ca6ba3f7daad2d86f424332c0aac1fb4e", "docs/images/apa-preview-v0.3.webp": "fc2ac3803d88c782e2a02cdfa345ce095e29a184a0ce7d0d6cdc3e7e024aabb0", "docs/images/codex-v0.2.webp": "ffb7333e4e6e3d6ddea2ec85f223c9669f5e272ac98f29918ed62516093dec4c", @@ -82,23 +84,27 @@ "docs/validation/v0.3.0/scholarly-live.json": "7a5b374db9fe17d5d7359df3f5479fe18e4842c2718667efd84dfda5ebbc759d", "docs/validation/v0.3.0/summary.json": "e347875d021adae233445dca9e893c450ff3670cb1df0aa5905b4880f2cc5189", "docs/validation/v0.3.0/windows.xml": "9841319d4bb4ccff69cf96bfb237009594f8268afc136272091cbe92aa4318e5", - "docs/wiki-pages.json": "86aa434dec8480bb23dfdffa762217852e66eee446fc4e6d3693cb89eafe5a76", + "docs/wiki-pages.json": "98ef2900d003c13341df016967d7657049d5917949b981291265cf72f25ff0a5", "examples/README.md": "0b7b83630202501a3ba6ad070708419aa23e32c8060ef9447491c6434043bbf6", "examples/fiction.json": "e3f90c0eed71102640c8a65ae86510e0258e9c90c3cc18730ed05329f632e88d", "examples/nonfiction.json": "af9196cf15dbe1d431f02c7d8ec11a720f25e1c3e30a756c1f9711b4c65a08cc", - "graphpaper/__init__.py": "9bd072c3728286a5b3b82595af0b447144b548ae6f557a664c67fb03cd23cdee", + "graphpaper/__init__.py": "188f5b5d03f39f47337a78afa66c10c0d5aee5476073126528fa5340b9ccc742", "graphpaper/apa.py": "e8c5b844c0f02f14c5a73d6128e4d174d4ad655e380b4d675215beb125cd717f", "graphpaper/author_web.py": "16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff", - "graphpaper/codex.py": "f72a5b2dc4d2bca3e3fdfabcdd7ce601bf0a449f0ad0fd8e032e1c9f78611bfe", + "graphpaper/codex.py": "29361635a46669b5c0ce7d06b903902f05a007424c0061a0df983bf6c81af95f", "graphpaper/demo.py": "5fcee68b786da702cd4365dca17e3f5df415fa26c7c6928568d64d8b7d693006", "graphpaper/desktop.py": "01ae73f8b863c01ebcb05575719dc44eb08dc515ae6ef64099e87cb36caee63e", "graphpaper/export.py": "91e2718409411dfea60bde80cd757ade7e00a20d6908be610a2d7eeb66548eb7", "graphpaper/folders.py": "37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8", - "graphpaper/graph.py": "36310ecd1e89af94f799c4fbc4611d90fea640edcbb07f06f92b832f90300770", - "graphpaper/graphify_adapter.py": "3db9a67427664b2ad4b3d2bc31089f162a4d1868f41a0d07e51e7e1ff02dc95f", + "graphpaper/graph.py": "eb9226bb03fc71b1d5eb309b833fa415efe5117b070500cef647704b334cdefe", + "graphpaper/graphify_adapter.py": "5c4ef54058240b3e8f82daed1d7d7180ee839c43811d7cbb824f35d040a3d1af", + "graphpaper/graphify_gateway.py": "258f8fe1b41682f3a952fe62d4ecb55824f82eea30f3997997565a3a45ff0164", + "graphpaper/graphify_process.py": "a499fb74738bee070b1557c388312cce3448057d5be725f75bb5c926ac1e2237", + "graphpaper/graphify_runtime.py": "6a00b27097e68d00813c6ef9298e18cb3c01fe1349bc1e9e1034f9e460a9521f", + "graphpaper/graphify_worker.py": "362444c55086940a394c1569bed492fb8646ea968e6dc1a542e27f182444b868", "graphpaper/ingest.py": "6cb0d9b0d1412e08f627c045f6e70db44882bdf3fd18ff03ea8922a4af83cce8", - "graphpaper/models.py": "3ddd92b202fa052a7b19caf530c3cc92f30c5be53456f98a8eabe6e4815733b5", - "graphpaper/pipeline.py": "87938ec1cfe8773917f11b27795252f7b9c0100744e6a73e1a18e90d5750c729", + "graphpaper/models.py": "5323a5a3f85a3c2896488421cd9033cab11e4dd86002c997c0a7a959f5f01987", + "graphpaper/pipeline.py": "0bb49567246f85eef350adedbf0ba3f304ebd9a790b724c149a1864fb987752d", "graphpaper/polish.py": "8890acded2fee782f88c98177250b58625cd93a045895b5c2f5d286056c46ae7", "graphpaper/providers.py": "8e63b30cc76f80a3e4a9298e12a33f45a86c23670622bbd7dd782afa5f6266a6", "graphpaper/reasoning.py": "62f72e3dcdbae5dd6bae6eb08916bcdea8d8a6d768e8305bc4e9acb7c45d3edc", @@ -108,52 +114,65 @@ "graphpaper/science_models.py": "986446c848927d85aa57bc2d2ba9f248f656fac9f6acc67d056e8f55776258e8", "graphpaper/science_routes.py": "cd2e5c76035dcd7a9384c74b4264306da8429072359a476b9d4e060aee86bb69", "graphpaper/secrets.py": "2d90bfed7124875d50cd977c0c4f17c4cb19e79af307445bc85af99596c3766e", - "graphpaper/server.py": "71a473ac274d1fed9ba17f5955240ff1b289838f9d3364762660a80ff1a6af82", + "graphpaper/server.py": "2e267912ee0f264d5b74bc8e534b31b08686f971f0e01c7c23b338c32595a596", "graphpaper/storage.py": "38b0df820a0c4dae0199bf38656d529e726a4475fe7a47b66e41903e96d14eae", "graphpaper/studio_routes.py": "bc77494829dfff3961a1076f7a152a5f9119c34d2038abde25105a78b67c8ff9", "graphpaper/support.py": "2f4a3f5f7f6a725b15719a775155bdc73c32ce0df022d43b4fb53dbf7e862d8e", "graphpaper/voice.py": "2527ef556be695942e481bf3df099beb05937c7a059d0e1ece94d7ca32d0e51e", - "pyproject.toml": "25d097a076308455c0d5d8298b81229480f7c2eb267d06046b7ae237a9948f3f", - "requirements-desktop.txt": "182bf080861b4cfda8adcb7ce9debff7181b7619ab8452445e143ec8ebf7044e", - "requirements-dev.txt": "180b600f70aadb56ce7665a5697a5b788cbc10351189b3442927096a6632b78e", + "pyproject.toml": "69f548597f30f1419d810f0f49cdeec10cab73540f64505a846d1e0d7092fb92", + "requirements-desktop.txt": "c9df0bdb57d7e9586ae7c927b5dca32f6e20010e71fc71cc4a5656387a129892", + "requirements-dev.txt": "40b197f9671e3c6db4d47ca73d9679eabafe662a56d4824c6f9e64255e47a5ce", + "requirements-graphify.txt": "9eea3e6ea7fa3ceb1247518901d41d55a0813dc5133475172541c68c122454f1", "requirements.txt": "4e73f195c8a6d32b841b671200bf0dcaa91e5a042c585e935e3132ee5e4a02a3", - "scripts/build_release.ps1": "30d5b80ee5820f96ac8f6a7cd58e0cf8f3de4996ef67760b796fe979d4c021b8", + "scripts/build_release.ps1": "4770977bd2b5ca5d23188fbf1aa44f9f066114c25177abccc6c98ff4c8f5d3dc", "scripts/build_windows.ps1": "44e0008ff8221097ead7355aa184a60e824d2fcbf0c77e54c74ddfc8f3a54858", "scripts/bundle_codex.py": "651d08e26949dbda47bca14dbc368d97b82e11a28759792cb6096adcea174efa", "scripts/check_codex.py": "2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81", + "scripts/check_graphify.py": "355f5f04011d4bb12778de9d2c18a654a8f3d4b1bcdbb353cc1e028c93950968", "scripts/check_package.py": "ef863315ea3f21a8f94b412276849df73d1b4fb0aaa30d7890a62a426a6cc4b7", "scripts/check_scholarly.py": "a2169565bd8fc7f19f1f8ef6a0e4d79008267e327670cd615595b1045577a409", - "scripts/desktop_entry.py": "97db3eedabd7fa1397386419d627479c31c889d7f593f81f971b443ee27ea514", + "scripts/desktop_entry.py": "4f3cbd5a9487e8830b1ceedc3d9698112cdb7fc1a32ada878539410ae0c52e00", "scripts/docs_tool.py": "7fd2a681bae5fd4a49ae394c74639dee8e833ad08e8a156406c292af6db46572", - "scripts/native_smoke.py": "996cf5eb6a3d7d8e663bca3702374573e81077bd592cd51fe2cb3eec0f51a836", + "scripts/graphify_ui_checks.py": "9932b327a19776634754d76e49aef9fcf7030f6edd315f6ac6e043bd40458525", + "scripts/hooks/hook-graphify.py": "5659b57f3b3e11f9327b2da6e00c4bd82a523f7f978e3b9506811c2caf007ff3", + "scripts/native_smoke.py": "186fb1950a0ffae61d29f657c4d6a4acdc222744fc74962b7b0c49d3d4fe4a8d", "scripts/package_windows.py": "8b56c4235ca2bbac39657905fa4633d78273bc4df6c9e67558a7e893176af8b3", - "scripts/prepare_assets.py": "fc0bc061f925216b7fd273af4de26ee2bbb9c76326c87a490df84b432dab11ce", + "scripts/prepare_assets.py": "179b65a0662d329fadecd9a41c134a249ed1f3cc3123a814095ee231636d819d", + "scripts/prepare_graphify.py": "c81d4146c3b4a137b93bb61d16308c483a3e3a453501426569242d227bb58e03", "scripts/publish_gui.py": "46bf7dff734e16b31c322e45bbf80abcfa29d7fbbc93f4c8162a119ff9c79ef4", + "scripts/ui_graphify_smoke.py": "0f29a01d972ad63006aba345952bb9e092575c452eb9475c505c21d9267a3de3", "scripts/ui_science_smoke.py": "f50dd9288cdc7f9b9c63c81e9103a14b3a40eb5391e5135a05a14f0540e41a6b", "scripts/ui_smoke.py": "367cd36f8d03a180cc377678cf58db9d986c884928b4dc62c2e37d96898ccfcd", "scripts/ui_studio_smoke.py": "ccfa788ca2163403f71edd04a1e479057135d86ac6b98cc7858d074dff575c3f", "tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tests/conftest.py": "ec52ebc98e568bae0f9ae6223d5f48640f0698d7605987bdafded5c814bf5381", + "tests/graphify_fixtures.py": "6961e0ceca4a78fca57baa9d9ab9f748e587e8d73adb7fc2d25b791716202e00", + "tests/graphify_http_fixture.py": "86b51983fcb1d91b0138cb72cda7ba7941e30d4e9caf8f21c64b3d49a2e2265b", "tests/science_fixtures.py": "2c5f9cd56f2c3ae28e912bbada2f594d2c60761360614e568c21febc3661e043", "tests/test_api.py": "05e5e721805fdd9511fd7d7296984cb13ccb5a5d919f512d84822dbb99591908", "tests/test_codex_runtime.py": "35688888c892c30bf1e745ab94f09dcf7b8a5cf99c29019b5e15bb172cf3fb6a", "tests/test_core.py": "167baaef162cfb6212e70fd6a1354479ab31352b4ed1000281a8773260d5a6d5", "tests/test_desktop_and_publish.py": "71fae513bac9965c146b20035b84d006cfb80045fb521523cbff38a11ef37edc", "tests/test_documentation_tools.py": "37c0f4df10a6f2919bb01e1c06813de4efae4a941908a20b3489967f36043c6b", + "tests/test_graphify_extraction.py": "5acaded318b8f60c7c40322c79d10b4b94f7149c15a68a214e4f4c6b8c80a7ff", + "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", + "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", + "tests/test_graphify_validation.py": "530705bda1a617ff44aa015d218ebc42853e978f1ff905c3c669f5726260761c", "tests/test_native_bridge.py": "5b611fb9f94733d97d87ac6a45cecd8575e708c0ab687acf59468a76c213251a", "tests/test_pipeline.py": "28373af3ea4739c252de747710923f6945f06a3397b9e59344263c117f48487a", "tests/test_providers.py": "83737b8773b8a11936675fba26388848cf6d95f40ef4b6db25baf40baa9d79cb", - "tests/test_reasoning_controls.py": "ed9b3375d34870a06c3b0e5b76a38ada965e45fd2306b41dde34af3cef83e3e9", + "tests/test_reasoning_controls.py": "85d9b6170d857e4fddd74723ba38bda7eeb113b40f7c67c5c5ba93afb063ce47", "tests/test_release_packaging.py": "8a6246f68eec9feca65971ae18b4c558cd426e0783637db8aa6b7eadd9a0fcfc", "tests/test_safety.py": "41a739403206f67fa9b286f56eae6e07c668ec06f2f70f8a45a184bf8f74da6a", "tests/test_scholarly_research.py": "e3392f5b0957009c555620958a2ead17372dae292cf87b9fb5b5ce812b5dca96", "tests/test_science_integrity.py": "06cb517992a0744e5d73cc475945cad59813fce1d60e59378c6d260d631748e6", "tests/test_studio_update.py": "d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653", "tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44", - "ui/app.js": "d476019001e593fea353e4b788b4819525daa638081b6be589f546dd05a973a6", + "ui/app.js": "107baa50cbe008485128001fb2b3ceaa7a2d9245c3869492673463c1dee9fe53", + "ui/graphify.js": "1bad42f86764de73c5e9411cca64d0c86d3bdfecaa8682be3e0728663b85d18c", "ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493", "ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f", - "ui/index.html": "bb45449aa874dcd3e12886544a5aa54019d29e0afcf0603795cc6b973b1cb5b2", + "ui/index.html": "9ba7d2726d3dd33cd67aa6f5c5f2319a2c4ffdb1a280f37a91c4efbbc6fc055d", "ui/science.css": "0234fa0a9542afc269ea5b3914a80e0d574843d45f7033394d9e8edaa40ed91c", "ui/science.js": "0ea82fc5cf4f31ea929e3a145b3534507b33ddcf0e0c2d3b0b76046abfdfe30b", "ui/studio.css": "c143a880c50ed59b9d315afaacd20377d6e47c54a701ea105f5c0757cde43a24", diff --git a/pyproject.toml b/pyproject.toml index b4d4f38..a536bf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphpaper-studio" -version = "0.3.0" +version = "0.3.1" description = "A local-first graph-guided writing studio for nonfiction and fiction" readme = "README.md" requires-python = ">=3.11" @@ -24,10 +24,11 @@ pythonpath = ["."] addopts = "-ra" [tool.setuptools.data-files] -"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/icon.svg", "ui/graphpaper.ico", "ui/studio.js", "ui/studio.css"] +"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/studio.js", "ui/studio.css", "ui/science.js", "ui/science.css", "ui/graphify.js", "ui/icon.svg", "ui/graphpaper.ico"] [project.optional-dependencies] -desktop = ["pywebview>=6.0,<7"] +desktop = ["pywebview>=6.0,<7", "graphifyy[openai]==0.9.80"] +graphify = ["graphifyy[openai]==0.9.80"] [project.gui-scripts] graphpaper = "graphpaper.desktop:main" diff --git a/requirements-desktop.txt b/requirements-desktop.txt index deee42a..b44c65f 100644 --- a/requirements-desktop.txt +++ b/requirements-desktop.txt @@ -1,3 +1,4 @@ -r requirements.txt +-r requirements-graphify.txt pywebview>=6.0,<7 keyring>=25,<27; platform_system != "Windows" diff --git a/requirements-dev.txt b/requirements-dev.txt index aa1931e..9fbc9b1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ -r requirements.txt pytest>=8.4,<10 playwright>=1.55,<2 + +-r requirements-graphify.txt diff --git a/requirements-graphify.txt b/requirements-graphify.txt new file mode 100644 index 0000000..f2ee0bc --- /dev/null +++ b/requirements-graphify.txt @@ -0,0 +1,4 @@ +# Public upstream distribution; no private fork, unreleased patch or user-local dependency. +graphifyy[openai]==0.9.80 +openai==3.26.0 +tiktoken==0.14.0 diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index 645534f..1f5742f 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -6,12 +6,16 @@ python scripts/bundle_codex.py if ($LASTEXITCODE -ne 0) { throw 'Official Codex runtime verification failed.' } python scripts/check_codex.py if ($LASTEXITCODE -ne 0) { throw 'Codex runtime protocol check failed.' } -python -m PyInstaller --noconfirm --clean --windowed --onedir --name GraphPaper --paths . --icon ui/graphpaper.ico --add-data 'ui;ui' --add-data 'vendor;vendor' --collect-all webview scripts/desktop_entry.py +python scripts/prepare_graphify.py +if ($LASTEXITCODE -ne 0) { throw 'Public Graphify runtime verification failed.' } +python -m PyInstaller --noconfirm --clean --windowed --onedir --name GraphPaper --paths . --additional-hooks-dir scripts/hooks --icon ui/graphpaper.ico --add-data 'ui;ui' --add-data 'vendor;vendor' --collect-all webview scripts/desktop_entry.py if ($LASTEXITCODE -ne 0) { throw 'Windows desktop build failed.' } Copy-Item README.md, LICENSE -Destination dist/GraphPaper/ -Copy-Item docs/RELEASE-0.2.1.md -Destination dist/GraphPaper/WHATS-NEW.md +Copy-Item docs/RELEASE-0.3.1.md -Destination dist/GraphPaper/WHATS-NEW.md python scripts/check_package.py dist/GraphPaper if ($LASTEXITCODE -ne 0) { throw 'Compiled application self-test failed.' } +python scripts/check_graphify.py --executable dist/GraphPaper/GraphPaper.exe --out test-results/graphify-package.json +if ($LASTEXITCODE -ne 0) { throw 'Bundled public Graphify extraction check failed; do not publish.' } $version = python -c "from graphpaper import __version__; print(__version__)" $zip = "dist/GraphPaper-v$version-Windows-x64.zip" python scripts/package_windows.py dist/GraphPaper $zip diff --git a/scripts/check_graphify.py b/scripts/check_graphify.py new file mode 100644 index 0000000..bebcec5 --- /dev/null +++ b/scripts/check_graphify.py @@ -0,0 +1,108 @@ +"""Check the real external Graphify action in an isolated synthetic project. + +Default: deterministic inference, real public Graphify process. --live-codex +requires an explicitly selected, already signed-in official Codex profile. +No credentials are read/exported by this script; only Codex accesses its own +profile. No installed project database or saved GraphPaper settings are opened. +""" +from __future__ import annotations +import argparse +import json +import platform +from pathlib import Path +import sys +import tempfile +import time +from unittest.mock import patch +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from fastapi.testclient import TestClient +from graphpaper.server import create_app +from graphpaper.providers import Clients +from graphpaper.models import Settings +from graphpaper.graphify_runtime import check_runtime +from graphpaper.codex import close_all +from tests.graphify_fixtures import FixtureClients,fixture_settings,add_sources + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--executable',type=Path,help='Use the Graphify worker bundled in this compiled GraphPaper.exe') + parser.add_argument('--live-codex',action='store_true') + parser.add_argument('--codex-profile-root',type=Path,help='Directory containing the official codex-account profile; used only for authentication') + parser.add_argument('--codex-executable',type=Path) + parser.add_argument('--model',default='') + parser.add_argument('--effort',default='low') + parser.add_argument('--out',type=Path,default=ROOT/'test-results/graphify-action.json') + args=parser.parse_args() + if args.live_codex and (not args.codex_profile_root or not args.codex_executable): + parser.error('Live mode needs explicit official account and executable locations.') + report={'platform':platform.system(),'python':platform.python_version(),'packaged_graphify_worker':bool(args.executable), + 'real_graphify_process':True,'live_codex':args.live_codex,'synthetic_project':True,'ok':False} + patches=[] + if args.executable: + command=[str(args.executable.resolve()),'--graphify-worker'] + patches.append(patch('graphpaper.graphify_runtime.worker_command',lambda:command)) + for context in patches:context.start() + try: + runtime=check_runtime() + assert runtime['available'],runtime + report['graphify_version']=runtime['version'] + with tempfile.TemporaryDirectory(prefix='GraphPaper-isolated-extraction-') as folder: + app=create_app(Path(folder)) + settings=fixture_settings() + if args.live_codex: + settings=Settings(provider='codex',model=args.model,extraction_model=args.model, + extraction_reasoning_effort=args.effort,graph_engine='graphify',allow_cloud=True, + codex_executable=str(args.codex_executable.resolve()),max_calls=5,max_output_tokens=8000, + request_timeout_seconds=300,graphify_timeout_seconds=600) + class AccountLocation: + path=args.codex_profile_root.resolve()/'credentials.dpapi' + def get(self,name):return '' + app.state.runner.clients_factory=lambda s,v,j:Clients(s,AccountLocation(),j) + else: + FixtureClients.seen=[] + app.state.runner.clients_factory=FixtureClients + app.state.store.set_settings(settings.model_dump()) + try: + with TestClient(app) as client: + client.get('/');client.headers['X-GraphPaper']='1' + p=client.post('/api/projects',json={'title':'Synthetic external Graphify acceptance test'}).json() + add_sources(client,p['id']) + response=client.post('/api/projects/'+p['id']+'/jobs',json={'action':'graph'}) + assert response.status_code==200,response.text + jid=response.json()['id'];deadline=time.monotonic()+660;last='' + while time.monotonic() 1 and sys.argv[1] == '--graphify-worker': + from graphpaper.graphify_worker import main as graphify_main + raise SystemExit(graphify_main(sys.argv[2:])) try: from graphpaper.desktop import main main() diff --git a/scripts/graphify_ui_checks.py b/scripts/graphify_ui_checks.py new file mode 100644 index 0000000..7048113 --- /dev/null +++ b/scripts/graphify_ui_checks.py @@ -0,0 +1,75 @@ +"""Reusable graphical checks; real Graphify and provider transport, synthetic inference.""" +from pathlib import Path +import sys +import time +from playwright.sync_api import expect +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from tests.graphify_http_fixture import FixtureServer + + +def exercise(page,check,out): + with FixtureServer() as provider: + page.locator('[data-action="settings"]').click() + page.locator('#s-provider').select_option('openai-compatible') + page.locator('#s-base_url').fill(provider.url) + page.locator('#s-model').fill('fixture-writer') + page.locator('#s-extraction_model').fill('fixture-extractor') + page.locator('#s-allow_cloud').uncheck() + page.locator('#s-remember_keys').uncheck() + page.get_by_text('Advanced limits & Graphify',exact=True).click() + page.locator('#s-graph_engine').select_option('graphify') + page.locator('#s-graphify_executable').fill('') + page.locator('#s-graphify_timeout_seconds').fill('90') + page.get_by_role('button',name='Load available models',exact=True).click() + expect(page.locator('#modal-status')).to_contain_text('models loaded',timeout=15000) + page.locator('#s-extraction_reasoning_effort').select_option('high') + page.get_by_role('button',name='Check Graphify runtime',exact=True).click() + expect(page.locator('#graphify-runtime-status')).to_contain_text('Ready: Graphify 0.9.80',timeout=30000) + page.screenshot(path=str(out/'graphify-settings.png')) + check('Graphify runtime is available through the real graphical status check') + page.get_by_role('button',name='Save connections',exact=True).click() + expect(page.locator('.modal')).to_have_count(0) + settings=page.evaluate('() => state.settings') + assert settings['graph_engine']=='graphify' + assert settings['extraction_model']=='fixture-extractor' + assert settings['extraction_reasoning_effort']=='high' + check('External engine, extraction model and reasoning settings save from the interface') + page.evaluate('''async () => { + for (const [title,text,role] of [ + ['Synthetic operations','Eva maintains the Observatory. The Observatory records variable stars.','evidence'], + ['Synthetic funding','A research grant funds the Observatory. The grant requires public summaries.','evidence'], + ['Voice fixture','PRIVATE_VOICE_FIXTURE_DO_NOT_EXTRACT','voice']]) { + state.p=await api('/projects/'+state.p.id+'/sources/text',{title,text,role}); + } + state.tab='sources'; render(); + }''') + page.locator('[data-action="run-graph"]').first.click() + deadline=time.monotonic()+100 + while time.monotonic() state.job && ({state:state.job.state,error:state.job.error})') + if job and job['state']=='completed':break + if job and job['state'] in {'failed','cancelled'}:raise AssertionError(job) + page.wait_for_timeout(100) + else:raise AssertionError('Native external extraction timed out') + page.wait_for_selector('#graph-svg') + project=page.evaluate('() => state.p') + graph=project['graph'] + assert graph['coverage']['engine']=='external_graphify' + assert graph['nodes'] and graph['edges'] + assert graph['coverage']['model_requests']==len(provider.requests)==project['usage']['calls'] + assert all(r['model']=='fixture-extractor' and r.get('reasoning_effort')=='high' for r in provider.requests) + assert all('PRIVATE_VOICE_FIXTURE' not in str(r) for r in provider.requests) + check('Build graph runs the actual external Graphify worker through the selected model and effort') + check('Only evidence sources reach inference; no second Native extraction or uncounted request') + page.screenshot(path=str(out/'graphify-graph.png')) + page.get_by_role('button',name='Eva',exact=True).click() + provenance=page.locator('.evidence-card').filter(has_text='Source-file provenance') + expect(provenance).to_be_visible() + provenance.get_by_role('button',name='S1',exact=True).click() + expect(page.locator('.source-text')).to_contain_text('Eva maintains the Observatory') + page.get_by_role('button',name='Close dialog',exact=True).click() + check('External graph source provenance opens the correct original document') + return {'nodes':len(graph['nodes']),'edges':len(graph['edges']), + 'model_requests':len(provider.requests),'runtime':graph['coverage']['runtime_version'], + 'engine':graph['engine'],'reasoning':'high','model':'fixture-extractor','live_model':False} diff --git a/scripts/hooks/hook-graphify.py b/scripts/hooks/hook-graphify.py new file mode 100644 index 0000000..e7cd71b --- /dev/null +++ b/scripts/hooks/hook-graphify.py @@ -0,0 +1,20 @@ +"""PyInstaller hook for the pinned public Graphify command, not a fork.""" +from importlib.metadata import distributions +from PyInstaller.utils.hooks import collect_all, copy_metadata + +datas,binaries,hiddenimports=collect_all('graphify') +datas+=copy_metadata('graphifyy',recursive=True) +# Dynamic grammar imports are declared upstream. Shipping metadata without the +# corresponding grammar modules would not be a complete public runtime. +for dist in distributions(): + name=dist.metadata['Name'].lower() + if name.startswith('tree-sitter'): + package=name.replace('-','_') + d,b,h=collect_all(package) + datas+=d;binaries+=b;hiddenimports+=h +for package in ['openai','tiktoken','tiktoken_ext']: + d,b,h=collect_all(package) + datas+=d;binaries+=b;hiddenimports+=h + +for distribution_name in ["openai", "tiktoken"]: + datas += copy_metadata(distribution_name, recursive=True) diff --git a/scripts/native_smoke.py b/scripts/native_smoke.py index 181808e..679306e 100644 --- a/scripts/native_smoke.py +++ b/scripts/native_smoke.py @@ -18,7 +18,9 @@ def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--executable',type=Path) + parser.add_argument('--trace-source',action='store_true',help='Capture Python thread stacks for this isolated source test only') parser.add_argument('--science',action='store_true',help='Exercise the Science workspace in the actual native shell') + parser.add_argument('--graphify',action='store_true',help='Verify the actual external Graphify action using a synthetic loopback model') parser.add_argument('--out',type=Path,default=ROOT/'test-results/native') args=parser.parse_args() if sys.platform!='win32':raise SystemExit('This regression requires a native Windows desktop.') @@ -43,6 +45,9 @@ def check(name):report['checks'].append(name);print('PASS',name,flush=True) else: # Avoid Windows venv's redirector process so proc.pid owns the window. command=[str(Path(sys.base_prefix)/'python.exe'),'-m','graphpaper.desktop'] + if args.trace_source: + trace_code="import runpy,faulthandler;f=open("+repr(str(out/'thread-stacks.txt'))+",'w');faulthandler.dump_traceback_later(25,repeat=True,file=f);runpy.run_module('graphpaper.desktop',run_name='__main__')" + command=[str(Path(sys.base_prefix)/'python.exe'),'-c',trace_code] env['PYTHONPATH']=os.pathsep.join([str(ROOT),*sys.path]) log=(out/'native.log').open('w',encoding='utf-8') proc=subprocess.Popen(command,cwd=ROOT,env=env,stdout=log,stderr=subprocess.STDOUT) @@ -118,6 +123,10 @@ def cdp_ready(): expect(page.locator('.modal')).to_have_count(0) assert page.evaluate('() => state.p.research.plan.queries[0]')=='memory AND sleep' check('Native Science research protocol edits and saves without a model call') + if args.graphify: + from graphify_ui_checks import exercise + report['external_graphify']=exercise(page,check,out) + responsive(hwnd) page.locator('[data-action="settings"]').click();page.wait_for_selector('#s-provider') expect(page.locator('#s-reasoning_effort')).to_have_count(1) page.get_by_role('button',name='Close dialog',exact=True).click();responsive(hwnd) @@ -128,7 +137,14 @@ def cdp_ready(): # Invoke a real native Save As dialog through the actual JS bridge; # cancel it using the OS close button message, not a mocked return. page.evaluate("id => {window.__saveTest=null;window.pywebview.api.save_export(id,'md').then(r=>window.__saveTest=r).catch(e=>window.__saveTest={error:String(e)});}",pid) - dlg=wait_test(lambda:next((w for w in windows() if w['visible'] and w['class']=='#32770'),None)) + def export_dialog_ready(): + result=page.evaluate('() => window.__saveTest') + report['save_bridge_result']=result + if result is not None:raise AssertionError('Native export returned before opening its dialog: '+json.dumps(result)) + current=windows() + report['export_window_classes']=[{'class':w['class'],'visible':w['visible'],'title':w['title']} for w in current] + return next((w for w in current if w['visible'] and w['class']=='#32770'),None) + dlg=wait_test(export_dialog_ready,25) u.PostMessageW(dlg['hwnd'],0x10,0,0) result=wait_test(lambda:page.evaluate('() => window.__saveTest')) assert result=={'saved':False},result;responsive(hwnd) @@ -139,7 +155,7 @@ def cdp_ready(): page.locator('#manuscript').fill(draft) assert page.evaluate('() => Object.keys(pending).length')>0 u.PostMessageW(hwnd,0x10,0,0) - proc.wait(timeout=15) + proc.wait(timeout=30) assert proc.returncode==0,proc.returncode check('Windows close completes the save handshake and exits normally') with sqlite3.connect(data/'studio.sqlite3') as c:stored=json.loads(c.execute('SELECT data FROM projects WHERE id=?',(pid,)).fetchone()[0]) @@ -153,6 +169,8 @@ def cdp_ready(): subprocess.run(['taskkill','/PID',str(proc.pid),'/T','/F'],capture_output=True) proc.wait(timeout=10) log.close() + if (data/'desktop.log').exists(): + (out/'desktop.log').write_bytes((data/'desktop.log').read_bytes()) report['exit_code']=proc.returncode report['recursion_errors']='maximum recursion depth' in (out/'native.log').read_text(encoding='utf-8',errors='replace') if report['recursion_errors']:report['ok']=False diff --git a/scripts/prepare_assets.py b/scripts/prepare_assets.py index 8da0564..3fdcb99 100644 --- a/scripts/prepare_assets.py +++ b/scripts/prepare_assets.py @@ -42,7 +42,7 @@ def reports(): def manifest(): allowed_roots = {'.github','docs','examples','graphpaper','scripts','tests','ui'} - allowed_top = {'README.md','CONTRIBUTING.md','SECURITY.md','CHANGELOG.md','LICENSE','.gitignore','.gitattributes','pyproject.toml','requirements.txt','requirements-dev.txt','requirements-desktop.txt','GraphPaper.pyw','Open GraphPaper.vbs','Publish GraphPaper.pyw','Publish GraphPaper.vbs'} + allowed_top = {'README.md','CONTRIBUTING.md','SECURITY.md','CHANGELOG.md','LICENSE','.gitignore','.gitattributes','pyproject.toml','requirements.txt','requirements-dev.txt','requirements-desktop.txt','requirements-graphify.txt','GraphPaper.pyw','Open GraphPaper.vbs','Publish GraphPaper.pyw','Publish GraphPaper.vbs'} paths = set(subprocess.check_output(['git','ls-files','-z'], cwd=ROOT).decode().split('\0')) for folder in ['docs/images','docs/validation','examples','ui']: paths.update(p.relative_to(ROOT).as_posix() for p in (ROOT/folder).rglob('*') if p.is_file()) diff --git a/scripts/prepare_graphify.py b/scripts/prepare_graphify.py new file mode 100644 index 0000000..60e0774 --- /dev/null +++ b/scripts/prepare_graphify.py @@ -0,0 +1,70 @@ +"""Verify/install-time assets of the public pinned runtime for Windows releases. + +No credential or user-project access. pip installs requirements-graphify.txt; +this script verifies that distribution and prepares tokenizer cache/licences. +""" +from __future__ import annotations +import hashlib +import base64 +import importlib.metadata as md +import json +import os +from pathlib import Path +import sys + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from graphpaper.graphify_worker import probe, PINNED_VERSION + + +def main(): + result=probe() + if not result['available']: raise SystemExit(result['error']) + verified={} + for name in ['graphifyy','openai','tiktoken']: + distribution=md.distribution(name) + count=0 + for entry in distribution.files or []: + if not entry.hash or entry.hash.mode != 'sha256': continue + path=distribution.locate_file(entry) + if not path.is_file(): raise SystemExit('Missing installed dependency file: '+str(entry)) + value=base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).decode().rstrip('=') + if value != entry.hash.value: raise SystemExit('Installed dependency differs from its distribution RECORD: '+str(entry)) + count+=1 + if not count: raise SystemExit('Dependency has no verifiable distribution records: '+name) + verified[name]=count + result['distribution_record_files_verified']=verified + vendor=ROOT/'vendor';vendor.mkdir(exist_ok=True) + cache=vendor/'tiktoken';cache.mkdir(exist_ok=True) + os.environ['TIKTOKEN_CACHE_DIR']=str(cache) + import tiktoken + assert tiktoken.get_encoding('cl100k_base').encode('GraphPaper public Graphify runtime') + licenses=vendor/'graphify-licenses';licenses.mkdir(exist_ok=True) + distributions=[] + for distribution in sorted(md.distributions(),key=lambda d:d.metadata['Name'].lower()): + name=distribution.metadata['Name'] + # Include installed dependency licence files; no private home paths, + # installed account state, font files or executable provenance tokens. + records=[] + for file in distribution.files or []: + lower=str(file).lower() + if '.dist-info/' not in lower or not any(part in lower.split('/')[-1] for part in ('license','notice','copying')): + continue + path=distribution.locate_file(file) + if not path.is_file() or path.stat().st_size>2_000_000: continue + data=path.read_bytes() + try: data.decode('utf-8') + except UnicodeDecodeError: continue + out=licenses/name/Path(str(file)).name;out.parent.mkdir(parents=True,exist_ok=True);out.write_bytes(data) + records.append({'file':out.relative_to(vendor).as_posix(),'sha256':hashlib.sha256(data).hexdigest()}) + distributions.append({'name':name,'version':distribution.version,'licenses':records}) + if not any(d['name'].lower()=='graphifyy' and d['licenses'] for d in distributions): + raise SystemExit('Graphify licence was not found; do not publish a licence-incomplete package.') + result.update({'source':'https://pypi.org/project/graphifyy/'+PINNED_VERSION+'/', + 'upstream':'https://github.com/Graphify-Labs/graphify','patched_upstream':False, + 'tokenizer_cache':[{'file':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in sorted(cache.iterdir()) if p.is_file()], + 'distributions':distributions}) + (vendor/'graphify-runtime.json').write_text(json.dumps(result,indent=2)+'\n',encoding='utf-8') + print('Public Graphify runtime verified:',PINNED_VERSION,'with tokenizer cache and licences.',flush=True) + +if __name__=='__main__': main() diff --git a/scripts/ui_graphify_smoke.py b/scripts/ui_graphify_smoke.py new file mode 100644 index 0000000..62de9ec --- /dev/null +++ b/scripts/ui_graphify_smoke.py @@ -0,0 +1,51 @@ +"""Actual browser/provider-transport checks with public Graphify and synthetic model replies.""" +from pathlib import Path +import argparse +import json +import socket +import sys +import tempfile +import threading +import time +import traceback +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from graphpaper.server import create_app +from playwright.sync_api import sync_playwright +from graphify_ui_checks import exercise +import uvicorn + + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('--out',type=Path,default=ROOT/'test-results/graphify-ui') + args=parser.parse_args();out=args.out.resolve();out.mkdir(parents=True,exist_ok=True) + report={'real_http_browser':True,'real_graphify_process':True,'live_model':False,'checks':[],'page_errors':[],'ok':False} + def check(label):report['checks'].append(label);print('PASS',label,flush=True) + with tempfile.TemporaryDirectory(prefix='GraphPaper-graphify-browser-') as temp: + app=create_app(Path(temp));sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + server=uvicorn.Server(uvicorn.Config(app,log_level='error')) + thread=threading.Thread(target=lambda:server.run(sockets=[sock]),daemon=True);thread.start() + deadline=time.monotonic()+15 + while not server.started and time.monotonic()0 diff --git a/tests/test_graphify_gateway.py b/tests/test_graphify_gateway.py new file mode 100644 index 0000000..094599d --- /dev/null +++ b/tests/test_graphify_gateway.py @@ -0,0 +1,57 @@ +"""The local extraction endpoint has a deliberately small request contract.""" +from types import SimpleNamespace +import httpx +import pytest +from graphpaper.models import Settings +from graphpaper.graphify_gateway import Gateway,JobScope +from graphpaper.pipeline import Job +from graphpaper.secrets import Vault +from .graphify_fixtures import FixtureClients,fixture_settings + + +def parser(): + gateway=object.__new__(Gateway) + gateway.model='graphpaper-extraction' + gateway.clients=SimpleNamespace(settings=Settings()) + return gateway + + +def body(): + return {'model':'graphpaper-extraction','messages':[{'role':'system','content':'Extract a graph.'},{'role':'user','content':'Synthetic source text.'}],'stream':False} + + +@pytest.mark.parametrize('replacement',[{'model':'different-model'},{'stream':True},{'messages':[]},{'messages':[{'role':'user','content':'Text'}]},{'tools':[{'type':'function'}]},{'messages':[{'role':'system','content':'Text'},{'role':'user','content':[{'type':'image_url'}]}]}]) +def test_only_the_fixed_text_extraction_contract_is_accepted(replacement): + with pytest.raises(ValueError):parser().parse({**body(),**replacement}) + + +def test_text_block_input_is_supported(): + value=body();value['messages'][1]['content']=[{'type':'text','text':'First text'},{'type':'text','text':'Second text'}] + assert parser().parse(value)==('Extract a graph.','First text\nSecond text') + + +def test_job_auth_and_real_model_selection(tmp_path): + FixtureClients.seen=[] + job=Job('synthetic','graph');scope=JobScope(job,15) + clients=FixtureClients(fixture_settings(),Vault(tmp_path),scope) + with Gateway(clients,scope) as gateway: + address=gateway.url+'/chat/completions' + assert httpx.post(address,json=body()).status_code==401 + assert not FixtureClients.seen + response=httpx.post(address,json=body(),headers={'Authorization':'Bearer '+gateway.token},timeout=10) + assert response.status_code==200 + assert response.json()['usage']['total_tokens']==210 + assert gateway.completed==1 and job.usage['calls']==1 + assert gateway.receipts[0]['model']=='extract-fixture' + assert gateway.receipts[0]['reasoning_effort']=='high' + + +def test_provider_failure_is_not_retried(tmp_path): + class Failed(FixtureClients):behavior='error' + job=Job('synthetic','graph');scope=JobScope(job,15) + with Gateway(Failed(fixture_settings(),Vault(tmp_path),scope),scope) as gateway: + for _ in range(2): + response=httpx.post(gateway.url+'/chat/completions',json=body(),headers={'Authorization':'Bearer '+gateway.token},timeout=10) + assert response.status_code==400 + assert job.usage['calls']==1 + assert gateway.error is not None diff --git a/tests/test_graphify_provenance.py b/tests/test_graphify_provenance.py new file mode 100644 index 0000000..af42a2e --- /dev/null +++ b/tests/test_graphify_provenance.py @@ -0,0 +1,27 @@ +from graphpaper.models import Project,Source,Node,Edge,Graph +from graphpaper.graph import mine_motifs +from graphpaper.pipeline import make_angles,Job +from .conftest import ScriptedClients + + +def test_external_file_provenance_reaches_angle_evaluation_without_becoming_proof(): + p=Project(sources=[Source(id='S1',title='Synthetic source',text='Eva maintains the Observatory. This is a synthetic fixture.')]) + p.graph=Graph(nodes=[Node(id='eva',label='Eva',status='imported',source_ids=['S1']),Node(id='site',label='Observatory',status='imported',source_ids=['S1'])], + edges=[Edge(source='eva',target='site',relation='maintains',status='imported',source_ids=['S1'])],coverage={'engine':'external_graphify'}) + motifs=mine_motifs(p) + assert motifs[0]['source_ids']==['S1'] + clients=ScriptedClients() + make_angles(p,clients,Job(p.id,'angles')) + decisions=[call for call in clients.calls if call[0]=='decide' and 'candidates' in call[1]] + assert decisions + excerpt=decisions[0][1]['candidates'][0]['source_context'][0] + assert excerpt['source_id']=='S1' and 'Eva maintains' in excerpt['text'] + assert 'not proof' in excerpt['scope'] + assert p.angles[0].source_ids==['S1'] + assert p.graph.nodes[0].status=='imported' and not p.graph.nodes[0].evidence + + +def test_unavailable_and_voice_sources_do_not_become_angle_support(): + p=Project(sources=[Source(id='S1',title='Voice',text='Style only',role='voice')]) + p.graph=Graph(nodes=[Node(id='a',label='A',source_ids=['S1','S99']),Node(id='b',label='B')],edges=[Edge(source='a',target='b')]) + assert all(not c['source_ids'] for c in mine_motifs(p)) diff --git a/tests/test_graphify_validation.py b/tests/test_graphify_validation.py new file mode 100644 index 0000000..97eb951 --- /dev/null +++ b/tests/test_graphify_validation.py @@ -0,0 +1,55 @@ +"""Validation and compatibility tests for external graph results.""" +import time +import pytest +from graphpaper.graphify_adapter import validate_graph,load_graph,source_refs +from graphpaper.graphify_runtime import check_runtime +from graphpaper.graphify_gateway import JobScope +from graphpaper.pipeline import Job +from graphpaper.models import Source +from graphpaper.providers import ProviderError,Cancelled + + +@pytest.mark.parametrize('raw',[{},[],{'nodes':[],'edges':[]},{'nodes':[{'id':'a'}]}, + {'nodes':[{'id':'a'},{'id':'a'}],'edges':[]}, + {'nodes':[{'id':'a'}],'edges':[{'source':'a','target':'missing'}]}, + {'nodes':[{'id':False}],'edges':[]}, + {'nodes':[{'id':'a','label':None}],'edges':[]}]) +def test_invalid_graph_is_not_installed(raw): + with pytest.raises(ValueError):validate_graph(raw) + + +def test_json_must_be_complete_and_finite(tmp_path): + path=tmp_path/'graph.json' + for content in ['{"nodes":[','{"nodes":[{"id":"a"}],"edges":[],"weight":NaN}']: + path.write_text(content) + with pytest.raises(ValueError):load_graph(path,tmp_path) + + +def test_custom_runtime_path_must_exist(tmp_path): + status=check_runtime(str(tmp_path/'not-installed.exe')) + assert not status['available'] and status['selection']=='custom' + + +def test_file_attribution_is_limited_to_exported_sources(tmp_path): + root=tmp_path/'input';root.mkdir() + files={'source-0001.md':Source(id='S1',title='Synthetic document',text='Text.')} + assert source_refs({'source_file':'source-0001.md'},files,root)==['S1'] + assert source_refs({'source_file':'unknown.md'},files,root)==[] + assert source_refs({'source_file':'other/source-0001.md'},files,root)==[] + + +def test_expired_job_scope_reports_timeout(): + job=Job('synthetic','graph') + scope=JobScope(job,.001) + time.sleep(.005) + with pytest.raises(ProviderError,match='timeout'):scope.check() + + +def test_cancelled_scope_and_late_accounting(): + job=Job('synthetic','graph');scope=JobScope(job,10) + job.cancel.set() + with pytest.raises(Cancelled):scope.check() + job.cancel.clear();scope.close() + before=dict(job.usage) + scope.record_usage('fixture',{'input_tokens':200},'fixture') + assert job.usage==before diff --git a/tests/test_reasoning_controls.py b/tests/test_reasoning_controls.py index 87f8316..e77455a 100644 --- a/tests/test_reasoning_controls.py +++ b/tests/test_reasoning_controls.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace import json from unittest.mock import Mock,patch import httpx @@ -85,7 +86,7 @@ def popen(args,**kwargs): captured.append((args,kwargs));Path(args[args.index('-o')+1]).write_text('Answer',encoding='utf-8') proc=Mock();proc.communicate.return_value=('{"type":"turn.completed","usage":{"input_tokens":8,"output_tokens":2}}\n','');proc.returncode=0;proc.poll.return_value=0 return proc - with patch('graphpaper.codex.subprocess.Popen',popen): + with patch('graphpaper.codex.OwnedProcess',lambda args,**kwargs:SimpleNamespace(proc=popen(args,**kwargs),close=lambda:None)): assert codex.complete('System','User','test',reasoning_effort='ultra')=='Answer' assert 'model_reasoning_effort="ultra"' in captured[0][0] assert not captured[0][1].get('shell',False) diff --git a/ui/app.js b/ui/app.js index 7c16390..a7d293e 100644 --- a/ui/app.js +++ b/ui/app.js @@ -128,7 +128,7 @@ function urlDialog(){showModal('Import from the web','Fetches a public page once function sourceDialog(id,ev){const s=state.p.sources.find(s=>s.id===id);if(!s){toast('Source not found in this project.',true);return;}let text=esc(s.text);if(ev?.verified&&ev.start>=0)text=esc(s.text.slice(0,ev.start))+''+esc(s.text.slice(ev.start,ev.end))+''+esc(s.text.slice(ev.end));showModal(s.title,`${s.id} · ${fmt(words(s.text))} words · ${s.kind}`,`
${esc(s.role)}${esc(s.author)} ${esc(s.published)}
${s.url?`

${esc(s.url)}

`:''}
${text}
${s.warnings.map(w=>`
${esc(w)}
`).join('')}
`,btn('Save role','save-source-role','check','primary'),true);setTimeout(()=>$('#source-highlight')?.scrollIntoView({block:'center'}),40);} function briefDialog(){const b=state.p.brief,f=state.p.mode==='fiction';const field=(id,label,value,type='input')=>`
${type==='textarea'?``:``}
`;showModal('The creative brief','Your intention is a first-class input—not an afterthought.',`
${field('audience','Audience',b.audience)}${field('format','Format',b.format)}${field('direction','Central question / direction',b.direction,'textarea')}${field('voice','Voice & style',b.voice,'textarea')}${f?field('genre','Genre',b.genre)+field('pov','Point of view',b.pov)+field('tense','Tense',b.tense)+field('canon','Canon / non-negotiable world rules',b.canon,'textarea'):''}${field('avoid','Avoid',b.avoid,'textarea')}

${b.originality}% exploration · ${b.rigor}% discipline
Adjust the sliders beside the graph.

`,btn('Save brief','save-brief','check','primary'),true);} function angleDialog(id){const a=state.p.angles.find(a=>a.id===id)||{id:uid(),title:'',thesis:'',hook:'',why:'',counterargument:'',questions:[],motif:'author',node_ids:[],edge_ids:[],source_ids:[],scores:{},decision:'author',score_engine:'author',score:null};state.editingAngle=a;showModal(id?'Refine this angle':'Your angle, your judgment','Say what this piece should do. You do not have to accept a model’s recommendation.',`
`,btn('Save & select','save-angle','check','primary'),true);} -function settingsForm(){const s=state.settings;const opt=(v,label,current)=>``;const text=(id,label,val,attrs='')=>`
`;return `
${text('base_url','API base URL',s.base_url)}${text('model','Writing model ID',s.model,'list="model-list" placeholder="Choose any model your provider supports"')}${text('editor_model','Editor model (blank = same)',s.editor_model,'list="model-list"')}${text('extraction_model','Extraction model (blank = same)',s.extraction_model,'list="model-list"')}
${btn('Load available models','load-models','refresh','small')}
OpenRouter can also use the dedicated key below.
${text('jev_model','JEV model ID',s.jev_model)}
Advanced limits & Graphify
${text('max_calls','Provider requests per job',s.max_calls,'type="number" min="5" max="500"')}${text('max_output_tokens','Maximum output tokens per call',s.max_output_tokens,'type="number" min="1000" max="64000"')}${text('context_chars','Context budget (characters, not tokens)',s.context_chars,'type="number" min="20000" max="1500000"')}${text('max_candidates','Graph candidates to judge',s.max_candidates,'type="number" min="6" max="80"')}
${text('graphify_executable','Graphify executable (optional)',s.graphify_executable)}
Graphify is optional and must be installed separately. Its subprocess makes its own model calls; those are not included in GraphPaper’s provider-request budget or token totals. You can also import a Graphify graph.json without running it.
${btn('Test writer','test-writer','check','small')}${btn('Test JEV','test-jev','check','small')}${btn('Clear saved keys','clear-keys','trash','small ghost')}Tests use a small provider request.

Project storage: ${esc(s.data_directory)}

`;} +function settingsForm(){const s=state.settings;const opt=(v,label,current)=>``;const text=(id,label,val,attrs='')=>`
`;return `
${text('base_url','API base URL',s.base_url)}${text('model','Writing model ID',s.model,'list="model-list" placeholder="Choose any model your provider supports"')}${text('editor_model','Editor model (blank = same)',s.editor_model,'list="model-list"')}${text('extraction_model','Extraction model (blank = same)',s.extraction_model,'list="model-list"')}
${btn('Load available models','load-models','refresh','small')}
OpenRouter can also use the dedicated key below.
${text('jev_model','JEV model ID',s.jev_model)}
Advanced limits & Graphify
${text('max_calls','Provider requests per job',s.max_calls,'type="number" min="5" max="500"')}${text('max_output_tokens','Maximum output tokens per call',s.max_output_tokens,'type="number" min="1000" max="64000"')}${text('context_chars','Context budget (characters, not tokens)',s.context_chars,'type="number" min="20000" max="1500000"')}${text('max_candidates','Graph candidates to judge',s.max_candidates,'type="number" min="6" max="80"')}
${text('graphify_executable','Custom Graphify executable (blank = bundled)',s.graphify_executable)}
The Windows release includes Graphify. The external process uses the selected extraction model through GraphPaper, including Codex OAuth, and shares the job request budget. Leave the custom executable blank for the bundled runtime. Import graph.json remains a separate no-inference action.
${btn('Test writer','test-writer','check','small')}${btn('Test JEV','test-jev','check','small')}${btn('Clear saved keys','clear-keys','trash','small ghost')}Tests use a small provider request.

Project storage: ${esc(s.data_directory)}

`;} function showSettings(){showModal('Connections & settings','Bring your models. Keep your manuscripts local.',settingsForm(),btn('Save connections','save-settings','check','primary'),true);} async function saveSettings(){const s={...state.settings};for(const k of ['provider','base_url','model','editor_model','extraction_model','jev_provider','jev_model','graph_engine','graphify_executable'])s[k]=$('#s-'+k).value.trim();for(const k of ['max_calls','max_output_tokens','context_chars','max_candidates'])s[k]=Number($('#s-'+k).value);for(const k of ['allow_cloud','remember_keys','refine'])s[k]=$('#s-'+k).checked;s.api_keys={};for(const k of ['llm','openrouter','typesafe'])s.api_keys[k]=$('#key-'+k).value||null;state.settings=await api('/settings',s,'PUT');$('#modal-status').textContent='Saved. Keys are not returned to the browser.';} function chooseFile(accept,multiple,callback){const el=document.createElement('input');el.type='file';el.accept=accept;el.multiple=multiple;el.onchange=()=>callback([...el.files]).catch(e=>toast(e.message,true));el.click();} diff --git a/ui/graphify.js b/ui/graphify.js new file mode 100644 index 0000000..71249c0 --- /dev/null +++ b/ui/graphify.js @@ -0,0 +1,10 @@ +/* Public external Graphify runtime and job-scoped inference controls. */ +'use strict'; +const graphifyPreviousSettings=settingsForm; +settingsForm=function(){return graphifyPreviousSettings()+`

External Graphify

The Windows release includes the public Graphify 0.9.80 runtime. It builds the graph in a separate process, using your selected extraction model and reasoning level—including Codex / ChatGPT sign-in. No extra API key or Native extraction pass is used.

${btn('Check Graphify runtime','graphify-check','check','small')}

Choose Graphify in Advanced limits & Graphify above. Leave its custom executable path empty for the included runtime. A custom path is used explicitly, never silently replaced. Requests count toward the GraphPaper job budget. Cancelling an in-flight provider request may still consume allowance.

`;}; +const graphifyPreviousSave=saveSettings; +saveSettings=async function(){for(const k of ['graphify_timeout_seconds','graphify_chunk_tokens'])if($('#s-'+k))state.settings[k]=Number($('#s-'+k).value);await graphifyPreviousSave();}; +const graphifyPreviousAction=handleAction; +handleAction=async function(el){if(el.dataset.action==='graphify-check'){await saveSettings();$('#graphify-runtime-status').textContent='Checking the runtime without a model call…';const status=await api('/graphify/status');$('#graphify-runtime-status').textContent=status.available?`Ready: Graphify ${status.version} (${status.selection}).`:status.error;return;}return graphifyPreviousAction(el);}; +const graphifyPreviousDirection=directionHTML; +directionHTML=function(){let value=graphifyPreviousDirection();const p=state.p;if(!p)return value;const item=p.graph.nodes.find(n=>n.id===state.node)||p.graph.edges.find(e=>e.id===state.edge);if(item?.source_ids?.length){const block=`
Source-file provenance

Reported by Graphify; inspect the original before treating the relationship as evidence.

${item.source_ids.map(id=>btn(id,'source','file','small',`data-id="${esc(id)}"`)).join('')}
`;value=value.replace(/<\/div><\/aside>$/,block+'');}return value;}; diff --git a/ui/index.html b/ui/index.html index ca67363..0d3abed 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,7 @@ +

Opening your writing studio…

From 47a9d61cdbec157ac851a978d5ab7f578382b999 Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:47:02 +0200 Subject: [PATCH 2/6] Make external-runtime tests portable across standard and virtual Python installs --- publish-manifest.json | 4 ++-- scripts/native_smoke.py | 4 ++++ tests/test_graphify_extraction.py | 13 ++++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/publish-manifest.json b/publish-manifest.json index d5eca08..4c63fbe 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -135,7 +135,7 @@ "scripts/docs_tool.py": "7fd2a681bae5fd4a49ae394c74639dee8e833ad08e8a156406c292af6db46572", "scripts/graphify_ui_checks.py": "9932b327a19776634754d76e49aef9fcf7030f6edd315f6ac6e043bd40458525", "scripts/hooks/hook-graphify.py": "5659b57f3b3e11f9327b2da6e00c4bd82a523f7f978e3b9506811c2caf007ff3", - "scripts/native_smoke.py": "186fb1950a0ffae61d29f657c4d6a4acdc222744fc74962b7b0c49d3d4fe4a8d", + "scripts/native_smoke.py": "2b6abe52db4970a78f0c2b1310a68a9a721e589e20ce20623ae0c80c073e4747", "scripts/package_windows.py": "8b56c4235ca2bbac39657905fa4633d78273bc4df6c9e67558a7e893176af8b3", "scripts/prepare_assets.py": "179b65a0662d329fadecd9a41c134a249ed1f3cc3123a814095ee231636d819d", "scripts/prepare_graphify.py": "c81d4146c3b4a137b93bb61d16308c483a3e3a453501426569242d227bb58e03", @@ -154,7 +154,7 @@ "tests/test_core.py": "167baaef162cfb6212e70fd6a1354479ab31352b4ed1000281a8773260d5a6d5", "tests/test_desktop_and_publish.py": "71fae513bac9965c146b20035b84d006cfb80045fb521523cbff38a11ef37edc", "tests/test_documentation_tools.py": "37c0f4df10a6f2919bb01e1c06813de4efae4a941908a20b3489967f36043c6b", - "tests/test_graphify_extraction.py": "5acaded318b8f60c7c40322c79d10b4b94f7149c15a68a214e4f4c6b8c80a7ff", + "tests/test_graphify_extraction.py": "d7da1549de893fda3606da1deea3aa1c53d28e92d210261fbe65418cda9090c4", "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", "tests/test_graphify_validation.py": "530705bda1a617ff44aa015d218ebc42853e978f1ff905c3c669f5726260761c", diff --git a/scripts/native_smoke.py b/scripts/native_smoke.py index 679306e..e8bba1c 100644 --- a/scripts/native_smoke.py +++ b/scripts/native_smoke.py @@ -147,6 +147,7 @@ def export_dialog_ready(): dlg=wait_test(export_dialog_ready,25) u.PostMessageW(dlg['hwnd'],0x10,0,0) result=wait_test(lambda:page.evaluate('() => window.__saveTest')) + report['save_bridge_result']=result assert result=={'saved':False},result;responsive(hwnd) check('Native Save As dialog opens, cancels and returns without blocking the interface') page.screenshot(path=str(out/'native-writing.png')) @@ -154,14 +155,17 @@ def export_dialog_ready(): draft='# Native test\n\nThis pending edit must survive the native close button.' page.locator('#manuscript').fill(draft) assert page.evaluate('() => Object.keys(pending).length')>0 + closing_started=time.monotonic() u.PostMessageW(hwnd,0x10,0,0) proc.wait(timeout=30) + report['close_seconds']=round(time.monotonic()-closing_started,3) assert proc.returncode==0,proc.returncode check('Windows close completes the save handshake and exits normally') with sqlite3.connect(data/'studio.sqlite3') as c:stored=json.loads(c.execute('SELECT data FROM projects WHERE id=?',(pid,)).fetchone()[0]) assert stored['draft']==draft,stored['draft'];check('Pending manuscript text is persisted before the native process exits') assert not report['page_errors'],report['page_errors'] report['ok']=True + report.pop('export_window_classes',None) except Exception: report['error']=traceback.format_exc();raise finally: diff --git a/tests/test_graphify_extraction.py b/tests/test_graphify_extraction.py index 69b21a3..6fef476 100644 --- a/tests/test_graphify_extraction.py +++ b/tests/test_graphify_extraction.py @@ -80,7 +80,14 @@ class Slow(FixtureClients):behavior='slow' pid=prepare(client,app,Slow) before=app.state.store.get(pid) job=client.post('/api/projects/'+pid+'/jobs',json={'action':'graph'}).json() - assert Slow.entered.wait(25) + # Wait for actual inference readiness, not a fixed cold-start assumption. + # Fail immediately with the backend error if startup fails. + ready_deadline=time.monotonic()+60 + while not Slow.entered.wait(.1): + startup=client.get('/api/jobs/'+job['id']).json() + assert startup['state'] in {'queued','running'},startup + assert time.monotonic() Date: Wed, 7 Oct 2026 20:54:13 +0200 Subject: [PATCH 3/6] Wait for completed voice saves and avoid sub-clock-resolution timeout assertions --- publish-manifest.json | 4 ++-- scripts/ui_studio_smoke.py | 1 + tests/test_graphify_validation.py | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/publish-manifest.json b/publish-manifest.json index 4c63fbe..e5e69cb 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -143,7 +143,7 @@ "scripts/ui_graphify_smoke.py": "0f29a01d972ad63006aba345952bb9e092575c452eb9475c505c21d9267a3de3", "scripts/ui_science_smoke.py": "f50dd9288cdc7f9b9c63c81e9103a14b3a40eb5391e5135a05a14f0540e41a6b", "scripts/ui_smoke.py": "367cd36f8d03a180cc377678cf58db9d986c884928b4dc62c2e37d96898ccfcd", - "scripts/ui_studio_smoke.py": "ccfa788ca2163403f71edd04a1e479057135d86ac6b98cc7858d074dff575c3f", + "scripts/ui_studio_smoke.py": "cf3d51f68362ed9dce39ccfa76729772971470548997b379c122cd4979d5dce3", "tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tests/conftest.py": "ec52ebc98e568bae0f9ae6223d5f48640f0698d7605987bdafded5c814bf5381", "tests/graphify_fixtures.py": "6961e0ceca4a78fca57baa9d9ab9f748e587e8d73adb7fc2d25b791716202e00", @@ -157,7 +157,7 @@ "tests/test_graphify_extraction.py": "d7da1549de893fda3606da1deea3aa1c53d28e92d210261fbe65418cda9090c4", "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", - "tests/test_graphify_validation.py": "530705bda1a617ff44aa015d218ebc42853e978f1ff905c3c669f5726260761c", + "tests/test_graphify_validation.py": "c39741fcbfb30eca55723a93d84dbd771102ca96d6a34168c9974bfe8258225e", "tests/test_native_bridge.py": "5b611fb9f94733d97d87ac6a45cecd8575e708c0ab687acf59468a76c213251a", "tests/test_pipeline.py": "28373af3ea4739c252de747710923f6945f06a3397b9e59344263c117f48487a", "tests/test_providers.py": "83737b8773b8a11936675fba26388848cf6d95f40ef4b6db25baf40baa9d79cb", diff --git a/scripts/ui_studio_smoke.py b/scripts/ui_studio_smoke.py index b5f91ad..cc71b86 100644 --- a/scripts/ui_studio_smoke.py +++ b/scripts/ui_studio_smoke.py @@ -101,6 +101,7 @@ def bridge(req): page.get_by_role('button',name='Your writing voice',exact=True).click();assert 'direct verbs' in page.locator('#voice-instructions').input_value() page.locator('#voice-instructions').fill('Use precise verbs. Vary rhythm. Leave deliberate wit alone.') page.get_by_role('button',name='Save voice settings',exact=True).click() + page.wait_for_function("() => state.p.voice_profile?.instructions?.startsWith('Use precise') && !document.querySelector('.modal')") assert client.get(base).json()['voice_profile']['instructions'].startswith('Use precise') report['checks'].append('Learn, inspect and edit a persistent author voice profile') page.get_by_role('button',name='Your writing voice',exact=True).click();page.screenshot(path=str(out/'voice.png'),full_page=True);page.get_by_role('button',name='Close dialog',exact=True).click() diff --git a/tests/test_graphify_validation.py b/tests/test_graphify_validation.py index 97eb951..c808b9f 100644 --- a/tests/test_graphify_validation.py +++ b/tests/test_graphify_validation.py @@ -40,8 +40,10 @@ def test_file_attribution_is_limited_to_exported_sources(tmp_path): def test_expired_job_scope_reports_timeout(): job=Job('synthetic','graph') - scope=JobScope(job,.001) - time.sleep(.005) + scope=JobScope(job,10) + # Windows Python 3.12 can expose a coarse monotonic clock. Test the + # expiry predicate deterministically; the process test covers real time. + scope.deadline=time.monotonic()-1 with pytest.raises(ProviderError,match='timeout'):scope.check() From ae1c86de0fa2171fab87e41389cdb798cd43f6b2 Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:03:15 +0200 Subject: [PATCH 4/6] Synchronize workflow checks with refreshed project data and correct release footer --- publish-manifest.json | 6 +++--- scripts/graphify_ui_checks.py | 1 + scripts/ui_science_smoke.py | 4 ++-- ui/app.js | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/publish-manifest.json b/publish-manifest.json index e5e69cb..9c5bbd0 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -133,7 +133,7 @@ "scripts/check_scholarly.py": "a2169565bd8fc7f19f1f8ef6a0e4d79008267e327670cd615595b1045577a409", "scripts/desktop_entry.py": "4f3cbd5a9487e8830b1ceedc3d9698112cdb7fc1a32ada878539410ae0c52e00", "scripts/docs_tool.py": "7fd2a681bae5fd4a49ae394c74639dee8e833ad08e8a156406c292af6db46572", - "scripts/graphify_ui_checks.py": "9932b327a19776634754d76e49aef9fcf7030f6edd315f6ac6e043bd40458525", + "scripts/graphify_ui_checks.py": "6dc1e45478a10fa8e3d17190864b2145cd973a5a6e23c7500f0d99ddfb824dc6", "scripts/hooks/hook-graphify.py": "5659b57f3b3e11f9327b2da6e00c4bd82a523f7f978e3b9506811c2caf007ff3", "scripts/native_smoke.py": "2b6abe52db4970a78f0c2b1310a68a9a721e589e20ce20623ae0c80c073e4747", "scripts/package_windows.py": "8b56c4235ca2bbac39657905fa4633d78273bc4df6c9e67558a7e893176af8b3", @@ -141,7 +141,7 @@ "scripts/prepare_graphify.py": "c81d4146c3b4a137b93bb61d16308c483a3e3a453501426569242d227bb58e03", "scripts/publish_gui.py": "46bf7dff734e16b31c322e45bbf80abcfa29d7fbbc93f4c8162a119ff9c79ef4", "scripts/ui_graphify_smoke.py": "0f29a01d972ad63006aba345952bb9e092575c452eb9475c505c21d9267a3de3", - "scripts/ui_science_smoke.py": "f50dd9288cdc7f9b9c63c81e9103a14b3a40eb5391e5135a05a14f0540e41a6b", + "scripts/ui_science_smoke.py": "62b82e3505fd4198b79dd4d8b95674cafad9c794ae2943078e251446cf27688f", "scripts/ui_smoke.py": "367cd36f8d03a180cc377678cf58db9d986c884928b4dc62c2e37d96898ccfcd", "scripts/ui_studio_smoke.py": "cf3d51f68362ed9dce39ccfa76729772971470548997b379c122cd4979d5dce3", "tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -168,7 +168,7 @@ "tests/test_science_integrity.py": "06cb517992a0744e5d73cc475945cad59813fce1d60e59378c6d260d631748e6", "tests/test_studio_update.py": "d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653", "tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44", - "ui/app.js": "107baa50cbe008485128001fb2b3ceaa7a2d9245c3869492673463c1dee9fe53", + "ui/app.js": "9efe63b3576c2754fb6cc08c1500cfb29b8aa311821536a74a6a16079bf7882a", "ui/graphify.js": "1bad42f86764de73c5e9411cca64d0c86d3bdfecaa8682be3e0728663b85d18c", "ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493", "ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f", diff --git a/scripts/graphify_ui_checks.py b/scripts/graphify_ui_checks.py index 7048113..45cbe73 100644 --- a/scripts/graphify_ui_checks.py +++ b/scripts/graphify_ui_checks.py @@ -53,6 +53,7 @@ def exercise(page,check,out): page.wait_for_timeout(100) else:raise AssertionError('Native external extraction timed out') page.wait_for_selector('#graph-svg') + page.wait_for_function("() => state.p.graph.coverage?.engine==='external_graphify' && state.p.graph.nodes.length>0") project=page.evaluate('() => state.p') graph=project['graph'] assert graph['coverage']['engine']=='external_graphify' diff --git a/scripts/ui_science_smoke.py b/scripts/ui_science_smoke.py index 83294fa..efa9a1f 100644 --- a/scripts/ui_science_smoke.py +++ b/scripts/ui_science_smoke.py @@ -86,12 +86,12 @@ def check(label):report['checks'].append(label);print('PASS',label,flush=True) assert all(r['metadata']['content_scope']=='full_text' for r in client.get(base).json()['research']['records']) page.get_by_role('button',name='Appraise included papers',exact=True).click();wait_job(page,'science-appraise') page.get_by_role('button',name='Evidence matrix',exact=True).click();expect(page.locator('.science-table tbody tr')).to_have_count(2) - assert 'uncertain' in page.locator('.science-table').inner_text();page.screenshot(path=str(out/'evidence.png'),full_page=True) + expect(page.locator('.science-table')).to_contain_text('uncertain');page.screenshot(path=str(out/'evidence.png'),full_page=True) check('Full-text retrieval and source-quoted appraisal populate the evidence matrix') page.get_by_role('button',name='Papers',exact=True).click();page.screenshot(path=str(out/'research.png'),full_page=True) page.get_by_role('button',name='Build manuscript outline',exact=True).click();wait_job(page,'science-outline');expect(page.locator('.outline-section')).to_have_count(5) page.get_by_role('button',name='Write manuscript',exact=True).click();wait_job(page,'science-draft');page.wait_for_selector('#manuscript') - assert '## Method' in page.locator('#manuscript').input_value();check('Scientific outline produces source-linked IMRaD manuscript and abstract') + page.wait_for_function("() => document.querySelector('#manuscript')?.value.includes('## Method')");check('Scientific outline produces source-linked IMRaD manuscript and abstract') page.get_by_role('button',name='APA preview',exact=True).click();page.wait_for_selector('.modal .prose') text=page.locator('.modal .prose').inner_text();assert 'Example & Researcher, 2024' in text and 'References' in text and '[S1]' not in text page.screenshot(path=str(out/'apa-preview.png'),full_page=True);page.get_by_role('button',name='Close dialog',exact=True).click() diff --git a/ui/app.js b/ui/app.js index a7d293e..bc85108 100644 --- a/ui/app.js +++ b/ui/app.js @@ -65,7 +65,7 @@ function progressDone(t){const p=state.p;return t==='sources'?p.sources.length>0 function render(){ const p=state.p,s=state.settings; document.documentElement.dataset.theme=s.theme||'dark'; - $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; + $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; if(p&&state.tab==='graph'&&p.graph.nodes.length)requestAnimationFrame(drawGraph); setupDrops();paintJob(); } From e1b64107fb7f8db11e9182a6218acd09f86165b4 Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:10:14 +0200 Subject: [PATCH 5/6] Supply upstream Graphify hash seed at launch so frozen workers do not re-exec as Python --- docs/UPDATE-0.3.1.md | 2 ++ graphpaper/graphify_runtime.py | 5 ++++- publish-manifest.json | 6 +++--- tests/test_graphify_validation.py | 7 +++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/UPDATE-0.3.1.md b/docs/UPDATE-0.3.1.md index fbef858..106ccdd 100644 --- a/docs/UPDATE-0.3.1.md +++ b/docs/UPDATE-0.3.1.md @@ -50,6 +50,8 @@ The graphical Windows source launcher installs its desktop requirements, includi The **Windows ZIP includes the Graphify Python modules, runtime dependencies, distribution metadata, tokenizer cache, upstream licences/notices and official Codex executable**. GraphPaper launches a dedicated worker mode of its own executable to host the unmodified Graphify CLI; it does not require a separate system Python or Graphify installation. +The worker launch supplies `PYTHONHASHSEED=0` before Python starts, satisfying the upstream startup contract without its normal `python -m graphify` re-execution. A frozen GraphPaper executable is not treated as a general Python command. + Release preparation verifies installed dependency versions, prepares the tokenizer cache and requires the upstream licence. The package contains `vendor/graphify-runtime.json` inside its bundled assets, recording versions, public provenance and cache/licence checksums. Packaging tests must exercise the frozen Graphify worker, not merely check that the main window's HTTP assets load. A custom Graphify executable must match the supported release and have its OpenAI-compatible SDK dependencies installed. Its version probe cannot establish the health of an arbitrary custom environment; the included runtime has the stronger package-level verification. diff --git a/graphpaper/graphify_runtime.py b/graphpaper/graphify_runtime.py index dc0dc62..6453615 100644 --- a/graphpaper/graphify_runtime.py +++ b/graphpaper/graphify_runtime.py @@ -31,7 +31,10 @@ def isolated_environment(home: Path) -> dict[str,str]: env={k:v for k,v in os.environ.items() if k.lower() in names} for name in ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_CACHE_HOME','TEMP','TMP','TMPDIR']: env[name]=str(home) - env.update({'PYTHONIOENCODING':'utf-8','PYTHONUTF8':'1','PYTHONNOUSERSITE':'1', + # Upstream restarts as `python -m graphify` when no hash seed is supplied. + # A frozen GraphPaper.exe is not a general Python CLI; supply the seed + # before launching its worker so the unmodified upstream CLI stays there. + env.update({'PYTHONHASHSEED':'0','PYTHONIOENCODING':'utf-8','PYTHONUTF8':'1','PYTHONNOUSERSITE':'1', 'GRAPHIFY_NO_AUTO_REFRESH':'1','GRAPHIFY_QUERY_LOG_DISABLE':'1','GRAPHIFY_GOOGLE_WORKSPACE':'0', 'GRAPHIFY_MAX_RETRIES':'0','GRAPHIFY_MAX_RETRY_DEPTH':'0','GRAPHIFY_MAX_WORKERS':'1', 'GRAPHIFY_LLM_TEMPERATURE':'none','NO_PROXY':'127.0.0.1,localhost','no_proxy':'127.0.0.1,localhost'}) diff --git a/publish-manifest.json b/publish-manifest.json index 9c5bbd0..fec1fc9 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -38,7 +38,7 @@ "docs/SECURITY.md": "0f818eb0cf4e8f94ffaaeb6da3cc2c2bc474674b673d185c61b86b2eb5fd3864", "docs/TROUBLESHOOTING.md": "a71223c4cff8c330e2a2170ce329c3cc3501baf829e3153f7503036b13be5b22", "docs/UPDATE-0.2.md": "cd71d4bb37bfdbb859fa4ea558a91e0cb61f900f0a4390d87f513abb8b8d815d", - "docs/UPDATE-0.3.1.md": "a793660a63ca9418ae566149d841075a7d78a91554890d5d8db4ba316c8398b5", + "docs/UPDATE-0.3.1.md": "c1232dc6067211467ec713703966472bf75e22a114d62d0f5e0a40acdabff84f", "docs/UPDATE-0.3.md": "99076ec9ea12986d79b93ec25a729c90403d3432d8e51598db5903b862fba459", "docs/VOICE-AND-PROSE.md": "b70fa33b57ba010a8de329e952c862c440370e66a68df02800f8203fe467a4d5", "docs/WINDOWS.md": "8b7ed8cbe01cf69d0dbc16967fa31cbce78ed28bb4475eb27eff7535737847ca", @@ -100,7 +100,7 @@ "graphpaper/graphify_adapter.py": "5c4ef54058240b3e8f82daed1d7d7180ee839c43811d7cbb824f35d040a3d1af", "graphpaper/graphify_gateway.py": "258f8fe1b41682f3a952fe62d4ecb55824f82eea30f3997997565a3a45ff0164", "graphpaper/graphify_process.py": "a499fb74738bee070b1557c388312cce3448057d5be725f75bb5c926ac1e2237", - "graphpaper/graphify_runtime.py": "6a00b27097e68d00813c6ef9298e18cb3c01fe1349bc1e9e1034f9e460a9521f", + "graphpaper/graphify_runtime.py": "7ae7629d7f4349ebfcba558a0147bbbab6f0f6113c6e2ad2dca3f716959d967f", "graphpaper/graphify_worker.py": "362444c55086940a394c1569bed492fb8646ea968e6dc1a542e27f182444b868", "graphpaper/ingest.py": "6cb0d9b0d1412e08f627c045f6e70db44882bdf3fd18ff03ea8922a4af83cce8", "graphpaper/models.py": "5323a5a3f85a3c2896488421cd9033cab11e4dd86002c997c0a7a959f5f01987", @@ -157,7 +157,7 @@ "tests/test_graphify_extraction.py": "d7da1549de893fda3606da1deea3aa1c53d28e92d210261fbe65418cda9090c4", "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", - "tests/test_graphify_validation.py": "c39741fcbfb30eca55723a93d84dbd771102ca96d6a34168c9974bfe8258225e", + "tests/test_graphify_validation.py": "6aa87f59e9e6be66d8dde810a2967dbffae254dcd253ed46d1c3fc73ad6ec1f1", "tests/test_native_bridge.py": "5b611fb9f94733d97d87ac6a45cecd8575e708c0ab687acf59468a76c213251a", "tests/test_pipeline.py": "28373af3ea4739c252de747710923f6945f06a3397b9e59344263c117f48487a", "tests/test_providers.py": "83737b8773b8a11936675fba26388848cf6d95f40ef4b6db25baf40baa9d79cb", diff --git a/tests/test_graphify_validation.py b/tests/test_graphify_validation.py index c808b9f..5099569 100644 --- a/tests/test_graphify_validation.py +++ b/tests/test_graphify_validation.py @@ -55,3 +55,10 @@ def test_cancelled_scope_and_late_accounting(): before=dict(job.usage) scope.record_usage('fixture',{'input_tokens':200},'fixture') assert job.usage==before + + +def test_external_worker_receives_hash_seed_before_python_start(tmp_path): + from graphpaper.graphify_runtime import isolated_environment + environment=isolated_environment(tmp_path/'home') + assert environment['PYTHONHASHSEED']=='0' + assert 'PYTEST_CURRENT_TEST' not in environment From 4952218966318497f695a565467eafd5d5eada39 Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:24:49 +0200 Subject: [PATCH 6/6] Record verified Windows Graphify release, live Codex extraction and cross-platform CI --- docs/QUALITY.md | 8 + docs/RELEASE-0.3.1.md | 6 + docs/images/graphify-graph-v0.3.1.webp | Bin 0 -> 53028 bytes docs/images/graphify-settings-v0.3.1.webp | Bin 0 -> 49922 bytes docs/validation/v0.3.1/browser-base.json | 25 + docs/validation/v0.3.1/browser-graphify.json | 24 + docs/validation/v0.3.1/browser-science.json | 22 + docs/validation/v0.3.1/browser-studio.json | 17 + .../v0.3.1/codex-live-packaged-worker.json | 53 + docs/validation/v0.3.1/codex-live-source.json | 53 + docs/validation/v0.3.1/codex-runtime.json | 7 + docs/validation/v0.3.1/github-ci.json | 104 ++ docs/validation/v0.3.1/native-package.json | 54 + docs/validation/v0.3.1/native-source.json | 153 +++ docs/validation/v0.3.1/package.json | 21 + docs/validation/v0.3.1/packaged-graphify.json | 53 + docs/validation/v0.3.1/public-runtime.json | 1154 +++++++++++++++++ docs/validation/v0.3.1/summary.json | 63 + docs/validation/v0.3.1/windows-unit.xml | 1 + publish-manifest.json | 21 +- 20 files changed, 1837 insertions(+), 2 deletions(-) create mode 100644 docs/images/graphify-graph-v0.3.1.webp create mode 100644 docs/images/graphify-settings-v0.3.1.webp create mode 100644 docs/validation/v0.3.1/browser-base.json create mode 100644 docs/validation/v0.3.1/browser-graphify.json create mode 100644 docs/validation/v0.3.1/browser-science.json create mode 100644 docs/validation/v0.3.1/browser-studio.json create mode 100644 docs/validation/v0.3.1/codex-live-packaged-worker.json create mode 100644 docs/validation/v0.3.1/codex-live-source.json create mode 100644 docs/validation/v0.3.1/codex-runtime.json create mode 100644 docs/validation/v0.3.1/github-ci.json create mode 100644 docs/validation/v0.3.1/native-package.json create mode 100644 docs/validation/v0.3.1/native-source.json create mode 100644 docs/validation/v0.3.1/package.json create mode 100644 docs/validation/v0.3.1/packaged-graphify.json create mode 100644 docs/validation/v0.3.1/public-runtime.json create mode 100644 docs/validation/v0.3.1/summary.json create mode 100644 docs/validation/v0.3.1/windows-unit.xml diff --git a/docs/QUALITY.md b/docs/QUALITY.md index e360105..9abc5cf 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,13 @@ # Validation and quality +## Version 0.3.1 validation + +217 automated Windows tests passed (1 platform-permission skip). All 44 browser workflow checks passed, all 10 native-source checks passed, and all 15 checks against the actual compiled executable passed. The compiled-native checks include a real public Graphify worker with synthetic model transport, source provenance, native Save As and save-on-close. + +Real Codex-backed extraction was verified separately with synthetic documents and the packaged Graphify worker, using the explicitly selected `gpt-6-luna` model at `low` reasoning. It used 1 model request(s), with no duplicate Native pass or API-key fallback. Exact outcomes, dependency versions and platform results are recorded in `docs/validation/v0.3.1`. This is not a claim of paid API testing for every provider or a macOS desktop build. + +The legacy native-test waits were too short on some cold Windows runs. The harness now waits for actual extraction readiness and records export diagnostics while retaining bounded cancellation and shutdown checks. Earlier reports below are historical. + Passing software tests and producing a better article are different questions. This page keeps recorded release checks, live service checks and editorial evaluation separate. ## Recorded validation: GraphPaper 0.3.0 diff --git a/docs/RELEASE-0.3.1.md b/docs/RELEASE-0.3.1.md index 4f84ec3..0cd4e69 100644 --- a/docs/RELEASE-0.3.1.md +++ b/docs/RELEASE-0.3.1.md @@ -25,3 +25,9 @@ Validation separates implementation tests, real upstream Graphify execution, a r Cancellation cannot refund a provider request that was already submitted. File attribution and quote matching do not prove scientific validity. macOS compatibility is not presented as a tested desktop build. Existing local installations are not modified by publishing this release. [Integration and distribution guide](https://github.com/AronAxe/GraphPaper/blob/main/docs/UPDATE-0.3.1.md) · [Public Graphify distribution](https://pypi.org/project/graphifyy/0.9.80/) + +## Verified build + +217 automated Windows tests passed (1 platform-permission skip). All 44 browser workflow checks passed, all 10 native-source checks passed, and all 15 checks against the actual compiled executable passed. The compiled-native checks include a real public Graphify worker with synthetic model transport, source provenance, native Save As and save-on-close. + +Real Codex-backed extraction was verified separately with synthetic documents and the packaged Graphify worker, using the explicitly selected `gpt-6-luna` model at `low` reasoning. It used 1 model request(s), with no duplicate Native pass or API-key fallback. Exact outcomes, dependency versions and platform results are recorded in `docs/validation/v0.3.1`. This is not a claim of paid API testing for every provider or a macOS desktop build. diff --git a/docs/images/graphify-graph-v0.3.1.webp b/docs/images/graphify-graph-v0.3.1.webp new file mode 100644 index 0000000000000000000000000000000000000000..845e67cb4da47e07c56d7c104f9a9d1c19a525bd GIT binary patch literal 53028 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 SQwjVbO@tk|8{ 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 X003L8-tG_p8#~5i$U{v)CQbkVr^*K< literal 0 HcmV?d00001 diff --git a/docs/images/graphify-settings-v0.3.1.webp b/docs/images/graphify-settings-v0.3.1.webp new file mode 100644 index 0000000000000000000000000000000000000000..3f45b7210922432c9b31b1c8cd894cd731f41b7e GIT binary patch literal 49922 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 BDDnUR literal 0 HcmV?d00001 diff --git a/docs/validation/v0.3.1/browser-base.json b/docs/validation/v0.3.1/browser-base.json new file mode 100644 index 0000000..f4ee166 --- /dev/null +++ b/docs/validation/v0.3.1/browser-base.json @@ -0,0 +1,25 @@ +{ + "mode": "HTTP browser", + "live_models": false, + "checks": [ + "Welcome and empty state", + "Illustrative graph renders with 18 nodes", + "Inspect graph node and persist pin", + "Path query and highlight", + "Edit and select an angle", + "Edit and reorder outline", + "Manuscript autosave and safe live preview", + "Clickable source references", + "Read and restore earlier version", + "Export options and valid Word output", + "Configure provider through UI (mock transport only)", + "Browser file-input upload and source ingestion", + "Complete graph \u2192 JEV \u2192 angle \u2192 outline \u2192 draft \u2192 review path with deterministic AI", + "Explicit revision job and versioning", + "Fiction from premise, scene generation and continuity ledger", + "Light theme", + "Responsive 390px layout without horizontal overflow" + ], + "page_errors": [], + "ok": true +} diff --git a/docs/validation/v0.3.1/browser-graphify.json b/docs/validation/v0.3.1/browser-graphify.json new file mode 100644 index 0000000..0ea60ad --- /dev/null +++ b/docs/validation/v0.3.1/browser-graphify.json @@ -0,0 +1,24 @@ +{ + "real_http_browser": true, + "real_graphify_process": true, + "live_model": false, + "checks": [ + "Graphify runtime is available through the real graphical status check", + "External engine, extraction model and reasoning settings save from the interface", + "Build graph runs the actual external Graphify worker through the selected model and effort", + "Only evidence sources reach inference; no second Native extraction or uncounted request", + "External graph source provenance opens the correct original document" + ], + "page_errors": [], + "ok": true, + "extraction": { + "nodes": 3, + "edges": 2, + "model_requests": 1, + "runtime": "0.9.80", + "engine": "Graphify 0.9.80 via openai-compatible", + "reasoning": "high", + "model": "fixture-extractor", + "live_model": false + } +} diff --git a/docs/validation/v0.3.1/browser-science.json b/docs/validation/v0.3.1/browser-science.json new file mode 100644 index 0000000..ea65c52 --- /dev/null +++ b/docs/validation/v0.3.1/browser-science.json @@ -0,0 +1,22 @@ +{ + "mode": "real HTTP browser", + "live_models": false, + "live_databases": false, + "checks": [ + "Three distinct writing modes on the welcome screen", + "Advertised Codex ultra and independent writer/editor/extractor efforts persist", + "Science project opens directly on the research desk", + "Research protocol, search terms, eligibility and APA author declarations save", + "Database search result cards carry metadata and access labels", + "Actual query, count and search status are visible in the audit log", + "Screened studies become correctly associated evidence sources", + "Full-text retrieval and source-quoted appraisal populate the evidence matrix", + "Scientific outline produces source-linked IMRaD manuscript and abstract", + "APA preview resolves author-year citations and references from metadata", + "APA Word export downloads an actual DOCX manuscript", + "Submission package requires author confirmation and rejects stale approval after edits", + "Research desk fits a smaller desktop window" + ], + "page_errors": [], + "ok": true +} diff --git a/docs/validation/v0.3.1/browser-studio.json b/docs/validation/v0.3.1/browser-studio.json new file mode 100644 index 0000000..c93cfb2 --- /dev/null +++ b/docs/validation/v0.3.1/browser-studio.json @@ -0,0 +1,17 @@ +{ + "mode": "real HTTP", + "live_models": false, + "checks": [ + "Voice sample upload through the actual file input", + "Learn, inspect and edit a persistent author voice profile", + "Project folder import and automatic source ownership", + "Idle folder watcher detects and imports new files without an AI call", + "Local explainable prose inspection", + "Humanizer proposal, side-by-side comparison and explicit acceptance", + "Separate deslopping pass and non-destructive rejection", + "Codex subscription provider selectable without an API key", + "New controls fit a smaller desktop window" + ], + "page_errors": [], + "ok": true +} diff --git a/docs/validation/v0.3.1/codex-live-packaged-worker.json b/docs/validation/v0.3.1/codex-live-packaged-worker.json new file mode 100644 index 0000000..ddb39b1 --- /dev/null +++ b/docs/validation/v0.3.1/codex-live-packaged-worker.json @@ -0,0 +1,53 @@ +{ + "platform": "Windows", + "python": "3.13.14", + "packaged_graphify_worker": true, + "real_graphify_process": true, + "live_codex": true, + "synthetic_project": true, + "ok": true, + "graphify_version": "0.9.80", + "job_state": "completed", + "usage": { + "calls": 1, + "input_tokens": 16775, + "output_tokens": 553, + "reported_cost": 0.0, + "unpriced_calls": 1 + }, + "nodes": 5, + "edges": 4, + "coverage": { + "engine": "external_graphify", + "runtime_version": "0.9.80", + "runtime_selection": "bundled", + "sources": 2, + "characters": 146, + "source_ids": [ + "S1", + "S2" + ], + "model": "gpt-6-luna", + "reasoning_effort": "low", + "model_requests": 1, + "requests": [ + { + "call": 1, + "model": "gpt-6-luna", + "provider": "codex", + "reasoning_effort": "low", + "prompt_tokens": 16775, + "completion_tokens": 553, + "total_tokens": 17328 + } + ], + "scope": "Enabled source text exported in full; Graphify controls chunking and extraction. No extra Native extraction pass was run." + }, + "source_links": [ + "S1", + "S2" + ], + "voice_source_excluded": true, + "no_extra_native_pass": true, + "settings_preserved": true +} diff --git a/docs/validation/v0.3.1/codex-live-source.json b/docs/validation/v0.3.1/codex-live-source.json new file mode 100644 index 0000000..5997c4f --- /dev/null +++ b/docs/validation/v0.3.1/codex-live-source.json @@ -0,0 +1,53 @@ +{ + "platform": "Windows", + "python": "3.13.14", + "packaged_graphify_worker": false, + "real_graphify_process": true, + "live_codex": true, + "synthetic_project": true, + "ok": true, + "graphify_version": "0.9.80", + "job_state": "completed", + "usage": { + "calls": 1, + "input_tokens": 16771, + "output_tokens": 566, + "reported_cost": 0.0, + "unpriced_calls": 1 + }, + "nodes": 5, + "edges": 3, + "coverage": { + "engine": "external_graphify", + "runtime_version": "0.9.80", + "runtime_selection": "python-environment", + "sources": 2, + "characters": 146, + "source_ids": [ + "S1", + "S2" + ], + "model": "gpt-6-luna", + "reasoning_effort": "low", + "model_requests": 1, + "requests": [ + { + "call": 1, + "model": "gpt-6-luna", + "provider": "codex", + "reasoning_effort": "low", + "prompt_tokens": 16771, + "completion_tokens": 566, + "total_tokens": 17337 + } + ], + "scope": "Enabled source text exported in full; Graphify controls chunking and extraction. No extra Native extraction pass was run." + }, + "source_links": [ + "S1", + "S2" + ], + "voice_source_excluded": true, + "no_extra_native_pass": true, + "settings_preserved": true +} diff --git a/docs/validation/v0.3.1/codex-runtime.json b/docs/validation/v0.3.1/codex-runtime.json new file mode 100644 index 0000000..638c3d6 --- /dev/null +++ b/docs/validation/v0.3.1/codex-runtime.json @@ -0,0 +1,7 @@ +{ + "ok": true, + "version": "codex-cli 0.160.1", + "unauthenticated_app_server_handshake": true, + "live_model_call": false, + "browser_oauth_completed": false +} diff --git a/docs/validation/v0.3.1/github-ci.json b/docs/validation/v0.3.1/github-ci.json new file mode 100644 index 0000000..02f08d4 --- /dev/null +++ b/docs/validation/v0.3.1/github-ci.json @@ -0,0 +1,104 @@ +[ + { + "run_id": 37672455260, + "name": "Documentation and wiki", + "head_sha": "e1b64107fb7f8db11e9182a6218acd09f86165b4", + "status": "completed", + "conclusion": "success", + "url": "https://github.com/AronAxe/GraphPaper/actions/runs/37672455260", + "jobs": [ + { + "name": "documentation", + "id": 112967393724, + "status": "completed", + "conclusion": "success" + }, + { + "name": "wiki", + "id": 112967473942, + "status": "completed", + "conclusion": "skipped" + } + ] + }, + { + "run_id": 37672455238, + "name": "Quality checks", + "head_sha": "e1b64107fb7f8db11e9182a6218acd09f86165b4", + "status": "completed", + "conclusion": "success", + "url": "https://github.com/AronAxe/GraphPaper/actions/runs/37672455238", + "jobs": [ + { + "name": "browser", + "id": 112967392642, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (windows-latest, 3.12)", + "id": 112967392840, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (ubuntu-latest, 3.13)", + "id": 112967393000, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (windows-latest, 3.13)", + "id": 112967393044, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (ubuntu-latest, 3.12)", + "id": 112967393490, + "status": "completed", + "conclusion": "success" + } + ] + }, + { + "run_id": 37672446358, + "name": "Quality checks", + "head_sha": "e1b64107fb7f8db11e9182a6218acd09f86165b4", + "status": "completed", + "conclusion": "success", + "url": "https://github.com/AronAxe/GraphPaper/actions/runs/37672446358", + "jobs": [ + { + "name": "browser", + "id": 112967361420, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (windows-latest, 3.12)", + "id": 112967361584, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (windows-latest, 3.13)", + "id": 112967361627, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (ubuntu-latest, 3.13)", + "id": 112967361673, + "status": "completed", + "conclusion": "success" + }, + { + "name": "tests (ubuntu-latest, 3.12)", + "id": 112967361679, + "status": "completed", + "conclusion": "success" + } + ] + } +] diff --git a/docs/validation/v0.3.1/native-package.json b/docs/validation/v0.3.1/native-package.json new file mode 100644 index 0000000..7c26866 --- /dev/null +++ b/docs/validation/v0.3.1/native-package.json @@ -0,0 +1,54 @@ +{ + "native_windows": true, + "packaged_executable": true, + "science_mode": true, + "live_models": false, + "checks": [ + "Actual Windows/WebView2 window starts and responds to Windows messages", + "Native bridge initializes with only four explicit methods and resolves its promise", + "Strict application CSP remains enabled", + "A Windows mouse click opens the project dialog without freezing the host", + "Native window supports typing and project creation", + "Native Science research protocol edits and saves without a model call", + "Graphify runtime is available through the real graphical status check", + "External engine, extraction model and reasoning settings save from the interface", + "Build graph runs the actual external Graphify worker through the selected model and effort", + "Only evidence sources reach inference; no second Native extraction or uncounted request", + "External graph source provenance opens the correct original document", + "Connections dialog opens and closes in the native window", + "Native Save As dialog opens, cancels and returns without blocking the interface", + "Windows close completes the save handshake and exits normally", + "Pending manuscript text is persisted before the native process exits" + ], + "page_errors": [], + "ok": true, + "bridge_methods": [ + "cancel_close", + "notify_ready", + "request_close", + "save_export" + ], + "native_child_classes": [ + "Chrome_RenderWidgetHostHWND", + "Chrome_WidgetWin_0", + "Chrome_WidgetWin_1", + "Intermediate D3D Window", + "WindowsForms10.Window.8.app.0.aec740_r21_ad1" + ], + "external_graphify": { + "nodes": 3, + "edges": 2, + "model_requests": 1, + "runtime": "0.9.80", + "engine": "Graphify 0.9.80 via openai-compatible", + "reasoning": "high", + "model": "fixture-extractor", + "live_model": false + }, + "save_bridge_result": { + "saved": false + }, + "close_seconds": 8.329, + "exit_code": 0, + "recursion_errors": false +} diff --git a/docs/validation/v0.3.1/native-source.json b/docs/validation/v0.3.1/native-source.json new file mode 100644 index 0000000..40027f4 --- /dev/null +++ b/docs/validation/v0.3.1/native-source.json @@ -0,0 +1,153 @@ +{ + "native_windows": true, + "packaged_executable": false, + "science_mode": true, + "live_models": false, + "checks": [ + "Actual Windows/WebView2 window starts and responds to Windows messages", + "Native bridge initializes with only four explicit methods and resolves its promise", + "Strict application CSP remains enabled", + "A Windows mouse click opens the project dialog without freezing the host", + "Native window supports typing and project creation", + "Native Science research protocol edits and saves without a model call", + "Connections dialog opens and closes in the native window", + "Native Save As dialog opens, cancels and returns without blocking the interface", + "Windows close completes the save handshake and exits normally", + "Pending manuscript text is persisted before the native process exits" + ], + "page_errors": [], + "ok": true, + "bridge_methods": [ + "cancel_close", + "notify_ready", + "request_close", + "save_export" + ], + "native_child_classes": [ + "Chrome_RenderWidgetHostHWND", + "Chrome_WidgetWin_0", + "Chrome_WidgetWin_1", + "Intermediate D3D Window", + "WindowsForms10.Window.8.app.0.aec740_r21_ad1" + ], + "save_bridge_result": null, + "export_window_classes": [ + { + "class": "Auto-Suggest Dropdown", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "_SearchEditBoxFakeWindow", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "tooltips_class32", + "visible": false, + "title": "" + }, + { + "class": "#32770", + "visible": true, + "title": "Save As" + }, + { + "class": "WindowsForms10.Window.8.app.0.aec740_r21_ad1", + "visible": true, + "title": "GraphPaper \u2014 a studio for connected thought" + }, + { + "class": "ComboLBox", + "visible": false, + "title": "" + }, + { + "class": "ComboLBox", + "visible": false, + "title": "" + }, + { + "class": "WorkerW", + "visible": false, + "title": "" + }, + { + "class": "WorkerW", + "visible": false, + "title": "" + }, + { + "class": "GDI+ Hook Window Class", + "visible": false, + "title": "GDI+ Window (python.exe)" + }, + { + "class": ".NET-BroadcastEventWindow.4.0.0.0.aec740.0", + "visible": false, + "title": ".NET-BroadcastEventWindow.4.0.0.0.aec740.0" + }, + { + "class": "IME", + "visible": false, + "title": "Default IME" + }, + { + "class": "MSCTFIME UI", + "visible": false, + "title": "MSCTFIME UI" + }, + { + "class": "MSCTFIME UI", + "visible": false, + "title": "MSCTFIME UI" + }, + { + "class": "IME", + "visible": false, + "title": "Default IME" + }, + { + "class": "IME", + "visible": false, + "title": "Default IME" + }, + { + "class": "IME", + "visible": false, + "title": "Default IME" + } + ], + "exit_code": 0, + "recursion_errors": false +} diff --git a/docs/validation/v0.3.1/package.json b/docs/validation/v0.3.1/package.json new file mode 100644 index 0000000..7d5b7e2 --- /dev/null +++ b/docs/validation/v0.3.1/package.json @@ -0,0 +1,21 @@ +{ + "ok": true, + "checks": { + "/health": { + "status": 200, + "bytes": 29 + }, + "/": { + "status": 200, + "bytes": 855 + }, + "/static/app.js": { + "status": 200, + "bytes": 70924 + }, + "/static/styles.css": { + "status": 200, + "bytes": 25023 + } + } +} diff --git a/docs/validation/v0.3.1/packaged-graphify.json b/docs/validation/v0.3.1/packaged-graphify.json new file mode 100644 index 0000000..700195b --- /dev/null +++ b/docs/validation/v0.3.1/packaged-graphify.json @@ -0,0 +1,53 @@ +{ + "platform": "Windows", + "python": "3.13.14", + "packaged_graphify_worker": true, + "real_graphify_process": true, + "live_codex": false, + "synthetic_project": true, + "ok": true, + "graphify_version": "0.9.80", + "job_state": "completed", + "usage": { + "calls": 1, + "input_tokens": 120, + "output_tokens": 90, + "reported_cost": 0.0, + "unpriced_calls": 0 + }, + "nodes": 3, + "edges": 2, + "coverage": { + "engine": "external_graphify", + "runtime_version": "0.9.80", + "runtime_selection": "bundled", + "sources": 2, + "characters": 146, + "source_ids": [ + "S1", + "S2" + ], + "model": "extract-fixture", + "reasoning_effort": "high", + "model_requests": 1, + "requests": [ + { + "call": 1, + "model": "extract-fixture", + "provider": "openai-compatible", + "reasoning_effort": "high", + "prompt_tokens": 120, + "completion_tokens": 90, + "total_tokens": 210 + } + ], + "scope": "Enabled source text exported in full; Graphify controls chunking and extraction. No extra Native extraction pass was run." + }, + "source_links": [ + "S1", + "S2" + ], + "voice_source_excluded": true, + "no_extra_native_pass": true, + "settings_preserved": true +} diff --git a/docs/validation/v0.3.1/public-runtime.json b/docs/validation/v0.3.1/public-runtime.json new file mode 100644 index 0000000..71526e5 --- /dev/null +++ b/docs/validation/v0.3.1/public-runtime.json @@ -0,0 +1,1154 @@ +{ + "available": true, + "package": "graphifyy", + "version": "0.9.80", + "required_version": "0.9.80", + "dependencies": { + "graphifyy": "0.9.80", + "openai": "3.26.0", + "tiktoken": "0.14.0", + "networkx": "3.7", + "numpy": "2.5.3", + "rapidfuzz": "3.14.6", + "tree-sitter": "0.25.2" + }, + "missing": [], + "distribution": "public PyPI, unmodified Graphify CLI", + "error": "", + "distribution_record_files_verified": { + "graphifyy": 240, + "openai": 1938, + "tiktoken": 14 + }, + "source": "https://pypi.org/project/graphifyy/0.9.80/", + "upstream": "https://github.com/Graphify-Labs/graphify", + "patched_upstream": false, + "tokenizer_cache": [ + { + "file": "9b5ad71b2ce5302211f9c61530b329a4922fc6a4", + "sha256": "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7" + } + ], + "distributions": [ + { + "name": "altgraph", + "version": "0.17.5", + "licenses": [ + { + "file": "graphify-licenses/altgraph/LICENSE", + "sha256": "6c194d6db0c64d45535d10c95142b9b0cda7b7dcc7f1ddee302b3d536f3dbe46" + } + ] + }, + { + "name": "annotated-doc", + "version": "0.0.5", + "licenses": [ + { + "file": "graphify-licenses/annotated-doc/LICENSE", + "sha256": "fff170779a6acbf65abdb405c087f1cee1786691e4a96a4034517e4a504a0cdf" + } + ] + }, + { + "name": "annotated-types", + "version": "0.8.0", + "licenses": [ + { + "file": "graphify-licenses/annotated-types/LICENSE", + "sha256": "fe1049884b1a0d9342901e88e07f32925d24b3121d9972b6a6805fb9824b095d" + } + ] + }, + { + "name": "anyio", + "version": "4.15.1", + "licenses": [ + { + "file": "graphify-licenses/anyio/LICENSE", + "sha256": "5361ac9dc58f2ef5fd2e9b09c68297c17f04950909bbc8023bdb82eacf22c2b0" + } + ] + }, + { + "name": "beautifulsoup4", + "version": "4.15.0", + "licenses": [ + { + "file": "graphify-licenses/beautifulsoup4/LICENSE", + "sha256": "55b4d8d4b1e5bc86d10efac91b74c87bcb77526b0f5b9edaf8b9cd2adc7397b2" + } + ] + }, + { + "name": "bottle", + "version": "0.13.4", + "licenses": [ + { + "file": "graphify-licenses/bottle/LICENSE", + "sha256": "af150257b1f7cce92b6207854f2e8f90ea9ae52d7cac758d13a47bd12e2a2443" + } + ] + }, + { + "name": "certifi", + "version": "2026.7.22", + "licenses": [ + { + "file": "graphify-licenses/certifi/LICENSE", + "sha256": "e93716da6b9c0d5a4a1df60fe695b370f0695603d21f6f83f053e42cfc10caf7" + } + ] + }, + { + "name": "cffi", + "version": "2.1.1", + "licenses": [ + { + "file": "graphify-licenses/cffi/LICENSE", + "sha256": "5ba24ddc57067f9249add644c3afc41a5d6dc37e23433ef759d95df370b0af63" + } + ] + }, + { + "name": "charset-normalizer", + "version": "3.5.2", + "licenses": [ + { + "file": "graphify-licenses/charset-normalizer/LICENSE", + "sha256": "18577485d3704f1a479ded8e573c0976cfed315fd2fd17983fa988da4c2f70d1" + } + ] + }, + { + "name": "click", + "version": "8.5.0", + "licenses": [ + { + "file": "graphify-licenses/click/LICENSE.txt", + "sha256": "9a8ad106a394e853bfe21f42f4e72d592819a22805d991b5f3275029292b658d" + } + ] + }, + { + "name": "clr_loader", + "version": "0.3.1", + "licenses": [ + { + "file": "graphify-licenses/clr_loader/LICENSE", + "sha256": "dd6bdca10cdc3b24c6b0b6cef5fd611b7feb6917f127833283ddcdb310d3b453" + } + ] + }, + { + "name": "colorama", + "version": "0.4.6", + "licenses": [ + { + "file": "graphify-licenses/colorama/LICENSE.txt", + "sha256": "cac35c02686e5d04a5a7140bfb3b36e73aed496656e891102e428886d7930318" + } + ] + }, + { + "name": "defusedxml", + "version": "0.7.1", + "licenses": [ + { + "file": "graphify-licenses/defusedxml/LICENSE", + "sha256": "b80ce9da8c42a1f91079627fbbe2bf27210ae108a0ffe5f077d5b08e076c24c8" + } + ] + }, + { + "name": "fastapi", + "version": "0.142.2", + "licenses": [ + { + "file": "graphify-licenses/fastapi/LICENSE", + "sha256": "4ec89ffc81485b97fec584b2d4a961032eeffe834453894fd9c1274906cc744e" + } + ] + }, + { + "name": "graphifyy", + "version": "0.9.80", + "licenses": [ + { + "file": "graphify-licenses/graphifyy/LICENSE", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + { + "file": "graphify-licenses/graphifyy/LICENSE-MIT", + "sha256": "36ecff6bbb97aca1be64e95f42538cf796adde5864edac583e620643c096931a" + }, + { + "file": "graphify-licenses/graphifyy/NOTICE", + "sha256": "3448ea175bba09f6fed8a743000139fb4c7112de83f164355b5540b158576621" + } + ] + }, + { + "name": "greenlet", + "version": "3.5.6", + "licenses": [ + { + "file": "graphify-licenses/greenlet/LICENSE", + "sha256": "ab977b654605670319509481ece49198f405a129372ac38c96f51cd96c2f452e" + }, + { + "file": "graphify-licenses/greenlet/LICENSE.PSF", + "sha256": "69efa689cc7aec7736236d3039c1d665e4b0c734a5bfea5abf633023abb2fff4" + } + ] + }, + { + "name": "h11", + "version": "0.16.0", + "licenses": [ + { + "file": "graphify-licenses/h11/LICENSE.txt", + "sha256": "37db5bb85926db28a427a25867f10b1232003aea1be69ccb851138adb8e6f361" + } + ] + }, + { + "name": "httpcore", + "version": "1.0.9", + "licenses": [ + { + "file": "graphify-licenses/httpcore/LICENSE.md", + "sha256": "fdcb59154c74cbaba16a11242f7740bea9f23d6feb5547917d8c5f94a80392a5" + } + ] + }, + { + "name": "httpcore2", + "version": "2.13.1", + "licenses": [ + { + "file": "graphify-licenses/httpcore2/LICENSE.md", + "sha256": "c4df125c807b0613501e943a5c92496dd11d2c16604ce396097a8dde396304d9" + } + ] + }, + { + "name": "httpx", + "version": "0.28.1", + "licenses": [ + { + "file": "graphify-licenses/httpx/LICENSE.md", + "sha256": "4ec59d544f12b5f539a3a716fd321ac58ccd8030b465221f2c880200cdf28d8d" + } + ] + }, + { + "name": "httpx2", + "version": "2.13.1", + "licenses": [ + { + "file": "graphify-licenses/httpx2/LICENSE.md", + "sha256": "7e7d6dbaf7fe160d39fd6e84fd02c33cab96bcbea34d36998d545bceb4a19c30" + } + ] + }, + { + "name": "idna", + "version": "3.20", + "licenses": [ + { + "file": "graphify-licenses/idna/LICENSE.md", + "sha256": "1a9a4f0e3d479a27240ddd59a9137a66ab4a0f9dfdc8ca6188cc0bfd85187f04" + } + ] + }, + { + "name": "iniconfig", + "version": "2.3.1", + "licenses": [ + { + "file": "graphify-licenses/iniconfig/LICENSE", + "sha256": "3409fa91f7ace557894632676656e32264fe5ef7581535725dc9a23774551bd4" + } + ] + }, + { + "name": "jiter", + "version": "0.17.0", + "licenses": [ + { + "file": "graphify-licenses/jiter/LICENSE", + "sha256": "e65c152aa65deaaa52019855bf1bbce4a0d592a048cb523b6ca691965741241e" + } + ] + }, + { + "name": "lxml", + "version": "6.1.3", + "licenses": [ + { + "file": "graphify-licenses/lxml/LICENSE.txt", + "sha256": "e7c3ce8d76331b0101cc46790ab43958ea90a364bcf962ed8763d5a818340e69" + }, + { + "file": "graphify-licenses/lxml/LICENSES.txt", + "sha256": "df682c668de65321677b5e4861dca4128a8bad8067f230956e9da3f985b3d398" + } + ] + }, + { + "name": "networkx", + "version": "3.7", + "licenses": [ + { + "file": "graphify-licenses/networkx/LICENSE.txt", + "sha256": "6025f323ea29dc2f0ee0abd9523bf910186f55b0e148e6ebf646c55566278e2e" + } + ] + }, + { + "name": "numpy", + "version": "2.5.3", + "licenses": [ + { + "file": "graphify-licenses/numpy/LICENSE.txt", + "sha256": "a804dff0ead9fadc5293456410bcbfc32bf024be9c4513459663fb7b442d2341" + }, + { + "file": "graphify-licenses/numpy/LICENSE.txt", + "sha256": "d544761558b510866c21f7e8a2d5716fca76e0b98117e3b6b86314db7244d150" + }, + { + "file": "graphify-licenses/numpy/COPYING", + "sha256": "aea41976c720f91bd19725c16916a1ace40eb1ea42620d340e587a5de4bbcea0" + }, + { + "file": "graphify-licenses/numpy/LICENSE", + "sha256": "d208152e2777ffd0126eacc1bd21c0320d69e4e6b622d21a2a12a47e8ef73763" + }, + { + "file": "graphify-licenses/numpy/dragon4_LICENSE.txt", + "sha256": "b0fd592d4ead563148e3977e7b589169d46a5ef2ff133c755a81af880a3fc919" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "a732977853530066790c42817087f4829b518dd98d340c916acbfd227e39d3cb" + }, + { + "file": "graphify-licenses/numpy/LICENSE", + "sha256": "585483905b13d4b42f800c55ba99e89d6e2e33ccd5f503e201c07531d148ff41" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "8db000e158c162e7249787852d8c69764706502ccee725c06641b31c1e95c8af" + }, + { + "file": "graphify-licenses/numpy/LICENSE.txt", + "sha256": "19060ef465e1797d682ee0fc3081bd25ab1395e995e43d25c9d13c95d9a3cd6b" + }, + { + "file": "graphify-licenses/numpy/LICENSE", + "sha256": "d78dbb208b80d92b4d333e41a4baae50d12444f46e53199fa7726a91de6e2da7" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "b4bc2f4fa1c95778f1ed3dd8f14706bece399475b978f9f34b5d3cd72521b48a" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "cf5619f25b112d5ce7c896efffc8e610bcb345dfadcc19f2c1460aa48fff2c9e" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "92c10699884321f1c2947fe2a70ab23b0fbf1507f756d89dd08f38c61fc3d4fa" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "41190663b77bee5302386495a07b7eabfbfa571aa0ae2f11da6764162062fc4f" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "322ac46d870c8aeead6c53adcccc839296fb3c8e456eae155081297daf7f8013" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "28cad82ce1379dc529df30da78ec012d7d0d1d576b03ddcb93388aee53659f59" + }, + { + "file": "graphify-licenses/numpy/LICENSE.md", + "sha256": "454ec7350e478e037061850b67395753b425b1cb0a34bbc26290b020bf89d763" + } + ] + }, + { + "name": "openai", + "version": "3.26.0", + "licenses": [ + { + "file": "graphify-licenses/openai/LICENSE", + "sha256": "636eb7d79da9bb6d515a4b3fd417aa26679eb3cf16396ddab4bc55fa74e616e4" + } + ] + }, + { + "name": "opentelemetry-api", + "version": "1.45.1", + "licenses": [ + { + "file": "graphify-licenses/opentelemetry-api/LICENSE", + "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + } + ] + }, + { + "name": "packaging", + "version": "26.3", + "licenses": [ + { + "file": "graphify-licenses/packaging/LICENSE", + "sha256": "cad1ef5bd340d73e074ba614d26f7deaca5c7940c3d8c34852e65c4909686c48" + }, + { + "file": "graphify-licenses/packaging/LICENSE.APACHE", + "sha256": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" + }, + { + "file": "graphify-licenses/packaging/LICENSE.BSD", + "sha256": "b70e7e9b742f1cc6f948b34c16aa39ffece94196364bc88ff0d2180f0028fac5" + } + ] + }, + { + "name": "pefile", + "version": "2024.8.26", + "licenses": [ + { + "file": "graphify-licenses/pefile/LICENSE", + "sha256": "b44409c067c6da52bfb54bb6624fb11a7b52157c3a13ae1400232f8196e86ad3" + } + ] + }, + { + "name": "pillow", + "version": "12.3.0", + "licenses": [ + { + "file": "graphify-licenses/pillow/LICENSE", + "sha256": "4f7866a74802c6326f81faff59a56546b6aec2b10b91973e0e9308de95e79857" + } + ] + }, + { + "name": "pip", + "version": "26.1.2", + "licenses": [ + { + "file": "graphify-licenses/pip/LICENSE.txt", + "sha256": "634300a669d49aeae65b12c6c48c924c51a4cdf3d1ff086dc3456dc8bcaa2104" + }, + { + "file": "graphify-licenses/pip/LICENSE.txt", + "sha256": "86eeee87be2a43f3ff1f56496f451f69243926f025fedbb033666c304c4c161b" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "e93716da6b9c0d5a4a1df60fe695b370f0695603d21f6f83f053e42cfc10caf7" + }, + { + "file": "graphify-licenses/pip/LICENSE.txt", + "sha256": "808e10c8a6ab8deb149ff9b3fb19f447a808094606d712a9ca57fead3552599d" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "cb5e8e7e5f4a3988e1063c142c60dc2df75605f4c46515e776e3aca6df976e14" + }, + { + "file": "graphify-licenses/pip/LICENSE.md", + "sha256": "b7a336abf3b04e180ec065cdd16e705d079e1cc7a14f910aa6e9187f36b9cd87" + }, + { + "file": "graphify-licenses/pip/COPYING", + "sha256": "492dedba85da5872f78e6091bcd1fea474d660d35acb4dee964b8aab3f007427" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "cad1ef5bd340d73e074ba614d26f7deaca5c7940c3d8c34852e65c4909686c48" + }, + { + "file": "graphify-licenses/pip/LICENSE.APACHE", + "sha256": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" + }, + { + "file": "graphify-licenses/pip/LICENSE.BSD", + "sha256": "b70e7e9b742f1cc6f948b34c16aa39ffece94196364bc88ff0d2180f0028fac5" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "86da0f01aeae46348a3c3d465195dc1ceccde79f79e87769a64b8da04b2a4741" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "29e0fd62e929850e86eb28c3fdccf0cefdf4fa94879011cffb3d0d4bed6d4db6" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "a9d66f1d526df02e29dce73436d34e56e8632f46c275bbdffc70569e882f9f17" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "1b22b049b5267d6dfc23a67bf4a84d8ec04b9fdfb1a51d360e42b4342c8b4154" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "09e8a9bcec8067104652c168685ab0931e7868f9c8284b66f5ae6edae5f1130b" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "f388fd38cad13112c1dc0f669bbe80e7f84541edbafb72f3030d2ca7642c3c9d" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "deed7c17a4318158190a3ea239cc879a5a50271cebb98ae7025f48fbe58dca15" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "b80816b0d530b8accb4c2211783790984a6e3b61922c2b5ee92f3372ab2742fe" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "b80816b0d530b8accb4c2211783790984a6e3b61922c2b5ee92f3372ab2742fe" + }, + { + "file": "graphify-licenses/pip/LICENSE", + "sha256": "33be7b7e8fa4fd19b1760e1a8ed8a668bdab852c91b692dd41424bcb725a9fca" + }, + { + "file": "graphify-licenses/pip/LICENSE.txt", + "sha256": "130e3a64d5fdd5d096a752694634a7d9df284469de86e5732100268041e3d686" + } + ] + }, + { + "name": "playwright", + "version": "1.63.0", + "licenses": [ + { + "file": "graphify-licenses/playwright/LICENSE", + "sha256": "7fab1461b41970ff376f1c9303a637076bfaaeb71cd12dd3a1c44aaf59a1a2b9" + } + ] + }, + { + "name": "pluggy", + "version": "1.6.0", + "licenses": [ + { + "file": "graphify-licenses/pluggy/LICENSE", + "sha256": "d6b65e6c213a5d0b577911d34d6e5949b9f59d76c238c5071a2f3fc16cfb2606" + } + ] + }, + { + "name": "proxy_tools", + "version": "0.1.0", + "licenses": [] + }, + { + "name": "pycparser", + "version": "3.0", + "licenses": [ + { + "file": "graphify-licenses/pycparser/LICENSE", + "sha256": "0c846399369ea76ddd7b5c44fe6d16497415fcf015f5cbb508c24bf98b81c5b1" + } + ] + }, + { + "name": "pydantic", + "version": "2.13.5", + "licenses": [ + { + "file": "graphify-licenses/pydantic/LICENSE", + "sha256": "a9e186f3ca16b5eef84318e7a701721351a00cb7b8ae3a4394b67b49e3529ef3" + } + ] + }, + { + "name": "pydantic_core", + "version": "2.46.5", + "licenses": [ + { + "file": "graphify-licenses/pydantic_core/LICENSE", + "sha256": "fbe7f615f18d135c07000e5a86325db27f42c7729699ef166ac1f7a3f4586bf4" + } + ] + }, + { + "name": "pyee", + "version": "13.0.1", + "licenses": [ + { + "file": "graphify-licenses/pyee/LICENSE", + "sha256": "1ff556ca206b107f10e1c562ae454d3292e0296c3510eb90eb7f114caee32a66" + } + ] + }, + { + "name": "Pygments", + "version": "2.21.0", + "licenses": [ + { + "file": "graphify-licenses/Pygments/LICENSE", + "sha256": "a9d66f1d526df02e29dce73436d34e56e8632f46c275bbdffc70569e882f9f17" + } + ] + }, + { + "name": "pyinstaller", + "version": "6.22.3", + "licenses": [ + { + "file": "graphify-licenses/pyinstaller/COPYING.txt", + "sha256": "0598064c7d2718e38d7914a7d08343b2fa008e3bea9ebba2aa7a6ffa5900dd64" + } + ] + }, + { + "name": "pyinstaller-hooks-contrib", + "version": "2026.8", + "licenses": [ + { + "file": "graphify-licenses/pyinstaller-hooks-contrib/LICENSE", + "sha256": "91d0baaff00773038e72c0a1fc9d5d2d38706b7a2b9c04f34296608f931b9cd0" + } + ] + }, + { + "name": "pypdf", + "version": "6.19.0", + "licenses": [ + { + "file": "graphify-licenses/pypdf/LICENSE", + "sha256": "a97ac230e5f33ef10a5367a850eb01f91f1a0b064e34742c7794d2294557f524" + } + ] + }, + { + "name": "pytest", + "version": "9.1.1", + "licenses": [ + { + "file": "graphify-licenses/pytest/LICENSE", + "sha256": "ca836a5f9ecca3b2f350230faa20a48fb8b145653b5568d784862df864706b9b" + } + ] + }, + { + "name": "python-docx", + "version": "1.2.0", + "licenses": [ + { + "file": "graphify-licenses/python-docx/LICENSE", + "sha256": "7652f271e46d0d533e9dc463f3b5fcbdcacf4d6a9c8d6b554d15efd0f37f6132" + } + ] + }, + { + "name": "python-multipart", + "version": "0.0.32", + "licenses": [ + { + "file": "graphify-licenses/python-multipart/LICENSE.txt", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + } + ] + }, + { + "name": "pythonnet", + "version": "3.2.0", + "licenses": [ + { + "file": "graphify-licenses/pythonnet/LICENSE", + "sha256": "e1dc3a6ff017a6800dc27a7220500f512b95fbcd996d38ebbbff85c693245369" + } + ] + }, + { + "name": "pywebview", + "version": "6.2.1", + "licenses": [ + { + "file": "graphify-licenses/pywebview/LICENSE", + "sha256": "4a988dd3598832cd3653de20dc33cb677d0fb53ab5551c879ca31280ae653675" + } + ] + }, + { + "name": "pywin32-ctypes", + "version": "0.2.3", + "licenses": [ + { + "file": "graphify-licenses/pywin32-ctypes/LICENSE.txt", + "sha256": "dfa83b3e2709adfcdb838d9ad55823ca674abb780e60563d9dd9544ccbf785e9" + } + ] + }, + { + "name": "RapidFuzz", + "version": "3.14.6", + "licenses": [ + { + "file": "graphify-licenses/RapidFuzz/LICENSE", + "sha256": "f34ccdffdff56fc417785915eedf18601d5479755df3c472b2c754c29802d683" + } + ] + }, + { + "name": "regex", + "version": "2026.9.29", + "licenses": [ + { + "file": "graphify-licenses/regex/LICENSE.txt", + "sha256": "3d220c06594b826bbabf110312f60f385f99e57e529fa4b9e536f79091f8b4c7" + } + ] + }, + { + "name": "requests", + "version": "2.34.2", + "licenses": [ + { + "file": "graphify-licenses/requests/LICENSE", + "sha256": "09e8a9bcec8067104652c168685ab0931e7868f9c8284b66f5ae6edae5f1130b" + }, + { + "file": "graphify-licenses/requests/NOTICE", + "sha256": "f5110972dedad2b4e9d314518daf3b7d72d6e02e499acd802181de6f74571dcc" + } + ] + }, + { + "name": "setuptools", + "version": "84.0.0", + "licenses": [ + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "86da0f01aeae46348a3c3d465195dc1ceccde79f79e87769a64b8da04b2a4741" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "ade78d04982d69972d444a8e14a94f87a2334dd3855cc80348ea8e240aa0df2d" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "86da0f01aeae46348a3c3d465195dc1ceccde79f79e87769a64b8da04b2a4741" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "458502e12d97bbf64438606a20044aa85eb05fb0a8a807bb35dbec253fd1fc04" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "86da0f01aeae46348a3c3d465195dc1ceccde79f79e87769a64b8da04b2a4741" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "9755a18519666e5f0f4cae3daad3d7012bcae48a600b31237d75e9fe134e6683" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "5a57cb4db85e2a2dd88c290628908add57e3451449e0a9a71fdfb38776fd759d" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "09f1c8c9e941af3e584d59641ea9b87d83c0cb0fd007eb5ef391a7e2643c1a46" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "cad1ef5bd340d73e074ba614d26f7deaca5c7940c3d8c34852e65c4909686c48" + }, + { + "file": "graphify-licenses/setuptools/LICENSE.APACHE", + "sha256": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" + }, + { + "file": "graphify-licenses/setuptools/LICENSE.BSD", + "sha256": "b70e7e9b742f1cc6f948b34c16aa39ffece94196364bc88ff0d2180f0028fac5" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "29e0fd62e929850e86eb28c3fdccf0cefdf4fa94879011cffb3d0d4bed6d4db6" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "b80816b0d530b8accb4c2211783790984a6e3b61922c2b5ee92f3372ab2742fe" + }, + { + "file": "graphify-licenses/setuptools/LICENSE.txt", + "sha256": "30c23618679108f3e8ea1d2a658c7ca417bdfc891c98ef1a89fa4ff0c9828654" + }, + { + "file": "graphify-licenses/setuptools/LICENSE", + "sha256": "5a57cb4db85e2a2dd88c290628908add57e3451449e0a9a71fdfb38776fd759d" + } + ] + }, + { + "name": "sniffio", + "version": "1.3.1", + "licenses": [ + { + "file": "graphify-licenses/sniffio/LICENSE", + "sha256": "652c878488d1456361e08c3f8607fd7ba59892a14103d15cce4ff93c85b5cc8b" + }, + { + "file": "graphify-licenses/sniffio/LICENSE.APACHE2", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + { + "file": "graphify-licenses/sniffio/LICENSE.MIT", + "sha256": "3e6dae555eb92787fc82d1d48355677f454c7f65aeb38d3f9e72bf9a3daf034b" + } + ] + }, + { + "name": "soupsieve", + "version": "2.10", + "licenses": [ + { + "file": "graphify-licenses/soupsieve/LICENSE.md", + "sha256": "76a0acdb98c3a63681e20b6f096699ef8cad5fb35d56eef8387e0fa71d797a9a" + } + ] + }, + { + "name": "starlette", + "version": "1.7.0", + "licenses": [ + { + "file": "graphify-licenses/starlette/LICENSE.md", + "sha256": "dcb95677a02240243187e964f941847d19b17821cf99e5afae684fab328c19bf" + } + ] + }, + { + "name": "tiktoken", + "version": "0.14.0", + "licenses": [ + { + "file": "graphify-licenses/tiktoken/LICENSE", + "sha256": "258463fc3788f1f9b6a3f1820d8a87481ac24018d53167597e25d3e9031851ca" + } + ] + }, + { + "name": "tree-sitter", + "version": "0.25.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter/LICENSE", + "sha256": "c40a115b3d7d429c839bc51ace3464c2f37c9559bad6c6251a86281c27ea5431" + } + ] + }, + { + "name": "tree-sitter-bash", + "version": "0.25.1", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-bash/LICENSE", + "sha256": "49bf33cf78ef5897e4e161ce1517df7de1ae5042a65b6bcfd44401e0fc606559" + } + ] + }, + { + "name": "tree-sitter-c", + "version": "0.24.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-c/LICENSE", + "sha256": "2e0110e07abef7c2548b26ec9d6969775617ca539a0dc8dbeeb14d6452c711d1" + } + ] + }, + { + "name": "tree-sitter-c-sharp", + "version": "0.23.5", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-c-sharp/LICENSE", + "sha256": "778fb7d63b8c1844da315648c02f325c4713a6f4a5d19644fd413421422776d3" + } + ] + }, + { + "name": "tree-sitter-cpp", + "version": "0.23.4", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-cpp/LICENSE", + "sha256": "2e0110e07abef7c2548b26ec9d6969775617ca539a0dc8dbeeb14d6452c711d1" + } + ] + }, + { + "name": "tree-sitter-elixir", + "version": "0.3.5", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-elixir/LICENSE", + "sha256": "6d1d968fb225eca367cb7f0b8831ab012a35d92b547e945e17ef8e7b05c3e5cc" + }, + { + "file": "graphify-licenses/tree-sitter-elixir/NOTICE", + "sha256": "ab7644c34e7bd8a38fc3ffa4b60b5c20a47491d56082308b7a407fc76f39bb6d" + } + ] + }, + { + "name": "tree-sitter-fortran", + "version": "0.6.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-fortran/LICENSE", + "sha256": "c0fc02a86ae2b179c4729694c523bbf42da674e5136ed8e12e3cb2f26556ea9f" + } + ] + }, + { + "name": "tree-sitter-go", + "version": "0.25.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-go/LICENSE", + "sha256": "2e0110e07abef7c2548b26ec9d6969775617ca539a0dc8dbeeb14d6452c711d1" + } + ] + }, + { + "name": "tree-sitter-groovy", + "version": "0.1.2", + "licenses": [] + }, + { + "name": "tree-sitter-java", + "version": "0.23.5", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-java/LICENSE", + "sha256": "52ed137b039cd9c46409bc22e89938af911c95b157feae2d040b51e6084369a7" + } + ] + }, + { + "name": "tree-sitter-javascript", + "version": "0.25.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-javascript/LICENSE", + "sha256": "2e0110e07abef7c2548b26ec9d6969775617ca539a0dc8dbeeb14d6452c711d1" + } + ] + }, + { + "name": "tree-sitter-json", + "version": "0.24.8", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-json/LICENSE", + "sha256": "2e0110e07abef7c2548b26ec9d6969775617ca539a0dc8dbeeb14d6452c711d1" + } + ] + }, + { + "name": "tree-sitter-julia", + "version": "0.23.1", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-julia/LICENSE", + "sha256": "74f800107f200f4e28da33e9c3f86230d37d8f4c0a4916f7111e800c395ee3a2" + } + ] + }, + { + "name": "tree-sitter-kotlin", + "version": "1.1.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-kotlin/LICENSE", + "sha256": "0eea8dc45e89deeb03c7799bbbc7b4688f365fb274562f4540ecfebdea82e727" + } + ] + }, + { + "name": "tree-sitter-lua", + "version": "0.5.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-lua/LICENSE.md", + "sha256": "9a32b02e4c917b1ce6b5e79d8ea81e25cefd7f27d89c7235f2afb262c06cf32e" + } + ] + }, + { + "name": "tree-sitter-objc", + "version": "3.0.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-objc/LICENSE", + "sha256": "099c44248f8cf353123211318680e93465587c005a4b3730ce8cb5334de043d6" + } + ] + }, + { + "name": "tree-sitter-php", + "version": "0.24.1", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-php/LICENSE", + "sha256": "664764fabf40c0b318e06324a5a12379092a2df25b8cbf2415f71bc0bb352346" + } + ] + }, + { + "name": "tree-sitter-powershell", + "version": "0.26.4", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-powershell/LICENSE", + "sha256": "1fca8454c0d77a73f9922a52d39e1f7c339f5da5d03f2766eff66b859fa19477" + } + ] + }, + { + "name": "tree-sitter-python", + "version": "0.25.0", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-python/LICENSE", + "sha256": "d724405ce238a22c0d35769c5a36b386ad5958192efe8bbb304fb2896254575f" + } + ] + }, + { + "name": "tree-sitter-ruby", + "version": "0.23.1", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-ruby/LICENSE", + "sha256": "ee006f02a3d856df282e409be2a86e24a65bb573a98b9c28343771141351bb6b" + } + ] + }, + { + "name": "tree-sitter-rust", + "version": "0.24.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-rust/LICENSE", + "sha256": "31d5b6f4243d5c7c6e1c4ebbbb9f6407bd1457a08bcc4f706521710341acba36" + } + ] + }, + { + "name": "tree-sitter-scala", + "version": "0.26.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-scala/LICENSE", + "sha256": "1f95ed26e1f4074074c9c7083e61c0a9e4c3b9f435745044995f3beb4ed28575" + } + ] + }, + { + "name": "tree-sitter-swift", + "version": "0.7.4", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-swift/LICENSE", + "sha256": "ee75f972f821e2f0407b5fc7a82ff508e00d35db84075bf2d9e59ee75cea0be4" + } + ] + }, + { + "name": "tree-sitter-typescript", + "version": "0.23.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-typescript/LICENSE", + "sha256": "49bf33cf78ef5897e4e161ce1517df7de1ae5042a65b6bcfd44401e0fc606559" + } + ] + }, + { + "name": "tree-sitter-verilog", + "version": "1.0.3", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-verilog/LICENSE", + "sha256": "8d2ff8d1e308828099436c43dc8bfbb8b6f368ad2a012ab9802a80ca22b03b53" + } + ] + }, + { + "name": "tree-sitter-zig", + "version": "1.1.2", + "licenses": [ + { + "file": "graphify-licenses/tree-sitter-zig/LICENSE", + "sha256": "0eea8dc45e89deeb03c7799bbbc7b4688f365fb274562f4540ecfebdea82e727" + } + ] + }, + { + "name": "truststore", + "version": "0.10.4", + "licenses": [ + { + "file": "graphify-licenses/truststore/LICENSE", + "sha256": "33be7b7e8fa4fd19b1760e1a8ed8a668bdab852c91b692dd41424bcb725a9fca" + } + ] + }, + { + "name": "typing-inspection", + "version": "0.4.4", + "licenses": [ + { + "file": "graphify-licenses/typing-inspection/LICENSE", + "sha256": "804b59b25f2c31bd278f9202a19ae49a3945aa2664387e2d0a128c7cacc61ec3" + } + ] + }, + { + "name": "typing_extensions", + "version": "4.16.0", + "licenses": [ + { + "file": "graphify-licenses/typing_extensions/LICENSE", + "sha256": "3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf" + } + ] + }, + { + "name": "urllib3", + "version": "2.8.0", + "licenses": [ + { + "file": "graphify-licenses/urllib3/LICENSE.txt", + "sha256": "130e3a64d5fdd5d096a752694634a7d9df284469de86e5732100268041e3d686" + } + ] + }, + { + "name": "uvicorn", + "version": "0.54.0", + "licenses": [ + { + "file": "graphify-licenses/uvicorn/LICENSE.md", + "sha256": "efe1acf3e62fb99c288b0ec73e5a773b7268ef4320fe757ea994214e4b63c371" + } + ] + } + ] +} diff --git a/docs/validation/v0.3.1/summary.json b/docs/validation/v0.3.1/summary.json new file mode 100644 index 0000000..84e6e47 --- /dev/null +++ b/docs/validation/v0.3.1/summary.json @@ -0,0 +1,63 @@ +{ + "version": "0.3.1", + "validated_at": "2026-10-07T19:20:22.041137+00:00", + "source_commit": "e1b64107fb7f8db11e9182a6218acd09f86165b4", + "windows_unit": { + "passed": 217, + "skipped": 1, + "failed": 0 + }, + "browser_checks": 44, + "native_source_checks": 10, + "native_packaged_checks": 15, + "live_codex": true, + "existing_installation_modified": false, + "macos_directly_tested": false, + "live_codex_result": { + "nodes": 5, + "edges": 4, + "coverage": { + "engine": "external_graphify", + "runtime_version": "0.9.80", + "runtime_selection": "bundled", + "sources": 2, + "characters": 146, + "source_ids": [ + "S1", + "S2" + ], + "model": "gpt-6-luna", + "reasoning_effort": "low", + "model_requests": 1, + "requests": [ + { + "call": 1, + "model": "gpt-6-luna", + "provider": "codex", + "reasoning_effort": "low", + "prompt_tokens": 16775, + "completion_tokens": 553, + "total_tokens": 17328 + } + ], + "scope": "Enabled source text exported in full; Graphify controls chunking and extraction. No extra Native extraction pass was run." + }, + "source_links": [ + "S1", + "S2" + ], + "no_extra_native_pass": true + }, + "public_graphify_version": "0.9.80", + "dependency_file_verification": { + "graphifyy": 240, + "openai": 1938, + "tiktoken": 14 + }, + "application_source_sha256": "26bcdd8224fb324555c6a7afd8415de884139191732fef41801c2d3df794e72e", + "linux_source_ci_passed": true, + "native_verification_note": "Actual Windows/WebView2 interaction; synthetic projects; bounded OS dialog and teardown waits. macOS not directly tested.", + "archive": "GraphPaper-v0.3.1-Windows-x64.zip", + "archive_size_bytes": 187511280, + "archive_sha256": "1a0690506aaa6a45d0e2d0ebc6967f715d8da96dfdad5fd02443bf6d2a5bbed3" +} diff --git a/docs/validation/v0.3.1/windows-unit.xml b/docs/validation/v0.3.1/windows-unit.xml new file mode 100644 index 0000000..e5fc817 --- /dev/null +++ b/docs/validation/v0.3.1/windows-unit.xml @@ -0,0 +1 @@ +C:\Users\aron\AppData\Local\Temp\GraphPaper-graphify-integration\tests\test_studio_update.py:172: Symlinks unavailable for this Windows account \ No newline at end of file diff --git a/publish-manifest.json b/publish-manifest.json index fec1fc9..c8cb7b6 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -28,12 +28,12 @@ "docs/NONFICTION.md": "d4bedf2e435dd549b18663151cc85104bb534420ac109600a3106b9670a9669a", "docs/PROJECTS-AND-SOURCES.md": "828179218c31bb9f50698e2d047a04b4a056b979800795a37cd790d29060e2dd", "docs/PUBLISHING.md": "324b83d5138b22bb29ca1737cbc0563595cc14f03177d478c478bde9109f975f", - "docs/QUALITY.md": "906170a9b6c71c1381dd2b9c0f5cc02e3cd52d8bfc2a574df578a730968c19c2", + "docs/QUALITY.md": "a97fcd3f83f38ce2fb23f45f090e26e25398cf57f1a599ace7c30f4c8f3ea51c", "docs/README.md": "a03d4fff2f1a5e66c84aa9af499796367d44ff2d3b946e5fc4601ec519b237c4", "docs/RELEASE-0.2.1.md": "42642432ff5c6017630d33a64726c5dca37abbbb36a476a901a59ec8887aa870", "docs/RELEASE-0.2.md": "c03a41903088590845d0da0707a562b9e6cbf4c5e1ff57d8794763f349093e44", "docs/RELEASE-0.3.0.md": "e32f30a3a9716c310be21dcf540cf9060384b0c7fdf69c8fc1c45d36c105cf29", - "docs/RELEASE-0.3.1.md": "d95db299580c5672a9814436192035d99212dae3b3ae5bb15fc41723067a2966", + "docs/RELEASE-0.3.1.md": "3879831312c10f64c9faceaac28066ef557825416d1ba75560729c514c16d8f8", "docs/SCIENCE.md": "9357a2f4ac714b6330aff4ccfe45ca0457d18e7816a73646314024823fd0ea00", "docs/SECURITY.md": "0f818eb0cf4e8f94ffaaeb6da3cc2c2bc474674b673d185c61b86b2eb5fd3864", "docs/TROUBLESHOOTING.md": "a71223c4cff8c330e2a2170ce329c3cc3501baf829e3153f7503036b13be5b22", @@ -48,6 +48,8 @@ "docs/images/evidence-v0.3.webp": "c707b06e1b70942769cb39cee36c8ec1e4fdf9a9aa625805c1b2f437853c63fb", "docs/images/folder-v0.2.webp": "a0a1fbbd5090c61a8593dc9f3410e9114360fa6e1b98caec488f79272b69be74", "docs/images/graph.webp": "c14057145f1d11e6aac7a67cd8e7b0d64a26121bfccca502cfb500da124c0d82", + "docs/images/graphify-graph-v0.3.1.webp": "d4f17de276177114038b076c565a4d1c265202e851f295cf3ca1e70ec22ce9a7", + "docs/images/graphify-settings-v0.3.1.webp": "7ba7c6919412204e10231381627430947c26ebfa74db0205398f865511db60c0", "docs/images/graphpaper-header.svg": "80bfe7844f469ef2c3d63dd7ca2321d6c24ebed7f5c56555d797227e81a7955e", "docs/images/humanizer-v0.2.webp": "3654ab85e4dee3a15a59c206a7c1f7a14b9f9793479e4938af0f32a9f7937b5e", "docs/images/light.webp": "caca0fb694a07bb66ebd68567c64718f1437b8b36d3b8327135a491302a2e2a9", @@ -84,6 +86,21 @@ "docs/validation/v0.3.0/scholarly-live.json": "7a5b374db9fe17d5d7359df3f5479fe18e4842c2718667efd84dfda5ebbc759d", "docs/validation/v0.3.0/summary.json": "e347875d021adae233445dca9e893c450ff3670cb1df0aa5905b4880f2cc5189", "docs/validation/v0.3.0/windows.xml": "9841319d4bb4ccff69cf96bfb237009594f8268afc136272091cbe92aa4318e5", + "docs/validation/v0.3.1/browser-base.json": "75d47ce0e0a2a16f449aec76a8d54db9a96e37ac88c28e6d704a0e2bd2945033", + "docs/validation/v0.3.1/browser-graphify.json": "17175226f8a40c0121ba99bb1fe8b8f90604d84cb777e158afcc24091d37f3af", + "docs/validation/v0.3.1/browser-science.json": "99d8148660b495dc09b488b17132fc4bf7a595593b9d474a81b19743715afda7", + "docs/validation/v0.3.1/browser-studio.json": "9b358f722c13b8858022eb051b4130bc17daa2d497d2f2237cb5751600754a03", + "docs/validation/v0.3.1/codex-live-packaged-worker.json": "99504f305dadf3d6f81bb512adcad504ae2f737f1dc561ac890ce3a298ca82a5", + "docs/validation/v0.3.1/codex-live-source.json": "bc78fff00853161ece04684fa1b71cfa6b0a9b201c3f5d250b511bc7c31b9f1b", + "docs/validation/v0.3.1/codex-runtime.json": "67c0d74ce424da86c4107fe88b95bc17d2aa147690a5fb31c4cb1664cf1d5d54", + "docs/validation/v0.3.1/github-ci.json": "0e087c9d2103f85d68800909789aea983f131182fe146e9927c8d2109388eeea", + "docs/validation/v0.3.1/native-package.json": "3c53ef5326973ebcf8d42bdac6b9e1ba0ad01a6d5ee23a97fedfe4640a51ab89", + "docs/validation/v0.3.1/native-source.json": "acf28144d4ef822e3730ba6e931dbdb09ecb5d8bf740da2b9446886ebeec22ed", + "docs/validation/v0.3.1/package.json": "c3e9e93da4fed9770386084084bbb868547929b03ee9a234bbcc4d5248824a01", + "docs/validation/v0.3.1/packaged-graphify.json": "437f1ba894f199111c6e5300cb506d7772dbb0eb1e0462c06ae850a424d506e2", + "docs/validation/v0.3.1/public-runtime.json": "5cefc72a36bf5836776dbf370198fa5810e29f8eccfa07af14c60df0bf3cb24b", + "docs/validation/v0.3.1/summary.json": "8794be9e9ce192b44897388c642d085798e0facec630cf7fd57b05acc506f5d0", + "docs/validation/v0.3.1/windows-unit.xml": "cfd269638b789633b08d3030c516f723b2b5b0e92195b273af3e0159d0e30571", "docs/wiki-pages.json": "98ef2900d003c13341df016967d7657049d5917949b981291265cf72f25ff0a5", "examples/README.md": "0b7b83630202501a3ba6ad070708419aa23e32c8060ef9447491c6434043bbf6", "examples/fiction.json": "e3f90c0eed71102640c8a65ae86510e0258e9c90c3cc18730ed05329f632e88d",