diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock new file mode 100644 index 0000000..12d88fd --- /dev/null +++ b/.github/workflows/actions.lock @@ -0,0 +1,125 @@ +# This file is machine-generated by `gh actions-lock`. +# Do not edit by hand; run `gh actions-lock` to update. +# Docs: https://gh.io/actions-lockfile +version: 'v0.0.2' +workflows: + '.github/workflows/boj-build.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'sonarsource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4' + '.github/workflows/casket-pages.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + '.github/workflows/codeql.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd' + '.github/workflows/dogfood-gate.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/elixir.yml': + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + '.github/workflows/generator-generic-ossf-slsa3-publish.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/hypatia-scan.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' + - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd' + '.github/workflows/instant-sync.yml': + - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' + '.github/workflows/pages.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' + - 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9' + '.github/workflows/push-email-notify.yml': + - 'hyperpolymath/smtp-notify-action@22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' + '.github/workflows/rescript-deno-ci.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + '.github/workflows/trustfile.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/workflow-linter.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' +dependencies: + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: 'v6.1.0' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': + ref: 'v6.0.0' + commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' + owner_id: 44036562 + repo_id: 513659658 + 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': + ref: 'v5.0.1' + commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' + owner_id: 44036562 + repo_id: 438112499 + 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3': + ref: 'v9.0.0' + commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' + owner_id: 44036562 + repo_id: 205262760 + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': + ref: 'v7.0.1' + commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': + ref: 'v7.0.0' + commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': + ref: 'v5.0.0' + commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' + owner_id: 44036562 + repo_id: 496012378 + uses: + - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': + ref: 'v2.0.5' + commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' + owner_id: 42048915 + repo_id: 356423100 + 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: 'v1.24.1' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 + 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd': + ref: 'v4.38.1' + commit: 'sha1-1c5b675653bb5c22dbe9b12b556ec555138e09fd' + owner_id: 9919 + repo_id: 259445878 + 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d': + ref: 'v2.12.1' + commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' + owner_id: 75048950 + repo_id: 623796603 + 'hyperpolymath/smtp-notify-action@22e7bdb322c430c1d0dac6b3bb307f4bb139d0be': + ref: 'v0.3.0' + commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' + owner_id: 6759885 + repo_id: 1352485172 + 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697': + ref: 'v4.0.1' + commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' + owner_id: 18365890 + repo_id: 220359305 + 'sonarsource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4': + ref: 'v8.2.2' + commit: 'sha1-ba9859eae8dd6bd29e412f25ddbbef3d032000f4' + owner_id: 545988 + repo_id: 366408409 diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index 210023b..f74c652 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. + name: Build on: push: diff --git a/.github/workflows/casket-pages.yml b/.github/workflows/casket-pages.yml index 46e5f8f..a6259e4 100644 --- a/.github/workflows/casket-pages.yml +++ b/.github/workflows/casket-pages.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: GitHub Pages on: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 85c56ea..ed22cc8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: CodeQL Security Analysis on: diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index b210adb..5bff34b 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # dogfood-gate.yml — Hyperpolymath Dogfooding Quality Gate diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index b1d8d94..a740866 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Thin wrapper around hyperpolymath/standards elixir-ci-reusable.yml. # See standards#174 for the reusable's purpose. diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 0f65c44..d566331 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml index 608cc3d..685d842 100644 --- a/.github/workflows/generator-generic-ossf-slsa3-publish.yml +++ b/.github/workflows/generator-generic-ossf-slsa3-publish.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 5922883..4a706cc 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # governance.yml — single wrapper calling the shared estate governance bundle # in hyperpolymath/standards instead of carrying per-repo copies. # diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index a090476..7a81a4f 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Hypatia Neurosymbolic CI/CD Security Scan name: Hypatia Security Scan diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index 6c92fa4..25a08fd 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Instant Forge Sync - Triggers propagation to all forges on push/release name: Instant Sync diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 9886e92..fc79947 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Label Triage # Classify newly-filed issues against the estate label taxonomy. diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index c80b676..af34c6b 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Labels # Applies the canonical estate label set from .github/labels.json. diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index cde47d6..ca54d84 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Mirror to Git Forges on: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7773c8f..11d401c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: GitHub Pages (Ddraig SSG) on: push: diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index f2c55b6..4388de8 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable # PUSH_EMAIL_ENABLED=true (the single on/off switch). Addresses are pre-filled; # sending needs the org SMTP secrets (SMTP_HOST/PORT/USER/PASS). Inherited by diff --git a/.github/workflows/rescript-deno-ci.yml b/.github/workflows/rescript-deno-ci.yml index bda3091..9b1e4ab 100644 --- a/.github/workflows/rescript-deno-ci.yml +++ b/.github/workflows/rescript-deno-ci.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. permissions: actions: read contents: read diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c44045e..69f269d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Scorecards supply-chain security on: diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 5dc5595..3505f25 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Secret Scanner on: diff --git a/.github/workflows/trustfile.yml b/.github/workflows/trustfile.yml index b945d6d..2b526cf 100644 --- a/.github/workflows/trustfile.yml +++ b/.github/workflows/trustfile.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Trustfile Validation - A2ML v2.1 integrity checks diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 92c7eec..8025552 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Prevention workflow - validates all workflows have proper security config name: Workflow Security Linter diff --git a/lib/evidence_graph/accounts/user_notifier.ex b/lib/evidence_graph/accounts/user_notifier.ex index 577079e..cabff60 100644 --- a/lib/evidence_graph/accounts/user_notifier.ex +++ b/lib/evidence_graph/accounts/user_notifier.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraph.Accounts.UserNotifier do + @moduledoc "Email notifications for account authentication." + import Swoosh.Email alias EvidenceGraph.Accounts.User diff --git a/lib/evidence_graph/api_keys.ex b/lib/evidence_graph/api_keys.ex index fe393b7..86bd6b4 100644 --- a/lib/evidence_graph/api_keys.ex +++ b/lib/evidence_graph/api_keys.ex @@ -94,14 +94,7 @@ defmodule EvidenceGraph.ApiKeys do case ArangoDB.query_read(aql, %{key_hash: key_hash}) do {:ok, [doc]} -> if doc["active"] do - # Update last_used_at (fire-and-forget, don't block validation) - Task.start(fn -> - ArangoDB.update("api_keys", doc["_key"], %{ - last_used_at: DateTime.to_iso8601(DateTime.utc_now()) - }) - end) - - {:ok, doc} + touch_valid_key(doc) else {:error, :revoked} end @@ -182,4 +175,15 @@ defmodule EvidenceGraph.ApiKeys do :crypto.hash(:sha256, plaintext_key) |> Base.encode16(case: :lower) end + + defp touch_valid_key(doc) do + # Update last_used_at (fire-and-forget, don't block validation) + Task.start(fn -> + ArangoDB.update("api_keys", doc["_key"], %{ + last_used_at: DateTime.to_iso8601(DateTime.utc_now()) + }) + end) + + {:ok, doc} + end end diff --git a/lib/evidence_graph/claims.ex b/lib/evidence_graph/claims.ex index 1af43a7..b139cfa 100644 --- a/lib/evidence_graph/claims.ex +++ b/lib/evidence_graph/claims.ex @@ -7,6 +7,7 @@ defmodule EvidenceGraph.Claims do alias EvidenceGraph.ArangoDB alias EvidenceGraph.Claims.Claim + alias EvidenceGraph.Evidence.Evidence @doc """ Get a claim by ID. @@ -95,22 +96,7 @@ defmodule EvidenceGraph.Claims do """ def update_claim(id, attrs) do with {:ok, claim} <- get_claim(id) do - changeset = Claim.changeset(claim, attrs) - - if changeset.valid? do - updates = - Ecto.Changeset.apply_changes(changeset) - |> Map.put(:updated_at, DateTime.utc_now()) - |> Claim.to_arango_doc() - |> Map.drop([:_key, :inserted_at]) - - case ArangoDB.update("claims", id, updates) do - {:ok, doc} -> {:ok, Claim.from_arango_doc(doc)} - error -> error - end - else - {:error, changeset} - end + update_claim_changeset(id, claim, attrs) end end @@ -168,7 +154,7 @@ defmodule EvidenceGraph.Claims do evidence = Enum.map(results, fn %{"evidence" => ev, "relationship" => rel} -> %{ - evidence: EvidenceGraph.Evidence.Evidence.from_arango_doc(ev), + evidence: Evidence.from_arango_doc(ev), weight: rel["weight"], confidence: rel["confidence"], reasoning: rel["reasoning"] @@ -200,7 +186,7 @@ defmodule EvidenceGraph.Claims do evidence = Enum.map(results, fn %{"evidence" => ev, "relationship" => rel} -> %{ - evidence: EvidenceGraph.Evidence.Evidence.from_arango_doc(ev), + evidence: Evidence.from_arango_doc(ev), weight: rel["weight"], confidence: rel["confidence"], reasoning: rel["reasoning"] @@ -213,4 +199,23 @@ defmodule EvidenceGraph.Claims do error end end + + defp update_claim_changeset(id, claim, attrs) do + changeset = Claim.changeset(claim, attrs) + + if changeset.valid? do + updates = + Ecto.Changeset.apply_changes(changeset) + |> Map.put(:updated_at, DateTime.utc_now()) + |> Claim.to_arango_doc() + |> Map.drop([:_key, :inserted_at]) + + case ArangoDB.update("claims", id, updates) do + {:ok, doc} -> {:ok, Claim.from_arango_doc(doc)} + error -> error + end + else + {:error, changeset} + end + end end diff --git a/lib/evidence_graph/contradictions.ex b/lib/evidence_graph/contradictions.ex index b873524..7c10219 100644 --- a/lib/evidence_graph/contradictions.ex +++ b/lib/evidence_graph/contradictions.ex @@ -109,18 +109,7 @@ defmodule EvidenceGraph.Contradictions do heuristic = case ArangoDB.query_read(heuristic_aql, %{investigation_id: investigation_id}) do {:ok, h_results} -> - Enum.map(h_results, fn r -> - %{ - id: "heuristic_#{r["claim_a"]["_key"]}_#{r["claim_b"]["_key"]}", - claim_a: Claim.from_arango_doc(r["claim_a"]), - claim_b: Claim.from_arango_doc(r["claim_b"]), - type: :same_entity_opposing, - severity: 0.3, - detected_by: :heuristic, - resolved: false, - resolution: nil - } - end) + parse_heuristic_contradictions(h_results) _ -> [] @@ -164,7 +153,7 @@ defmodule EvidenceGraph.Contradictions do # Most-contradicted claims: count how many contradictions each claim appears in claim_counts = contradictions - |> Enum.flat_map(fn c -> [c.claim_a, c.claim_b] end) + |> Enum.flat_map(&[&1.claim_a, &1.claim_b]) |> Enum.frequencies_by(& &1.id) |> Enum.sort_by(fn {_id, count} -> count end, :desc) |> Enum.take(20) @@ -173,8 +162,8 @@ defmodule EvidenceGraph.Contradictions do Enum.map(claim_counts, fn {_id, count} -> claim = contradictions - |> Enum.flat_map(fn c -> [c.claim_a, c.claim_b] end) - |> Enum.find(fn c -> Enum.any?(claim_counts, fn {cid, _} -> cid == c.id end) end) + |> Enum.flat_map(&[&1.claim_a, &1.claim_b]) + |> Enum.find(&counted_claim?(&1, claim_counts)) %{claim: claim, contradiction_count: count} end) @@ -308,4 +297,23 @@ defmodule EvidenceGraph.Contradictions do [] end end + + defp parse_heuristic_contradictions(h_results) do + Enum.map(h_results, fn r -> + %{ + id: "heuristic_#{r["claim_a"]["_key"]}_#{r["claim_b"]["_key"]}", + claim_a: Claim.from_arango_doc(r["claim_a"]), + claim_b: Claim.from_arango_doc(r["claim_b"]), + type: :same_entity_opposing, + severity: 0.3, + detected_by: :heuristic, + resolved: false, + resolution: nil + } + end) + end + + defp counted_claim?(claim, claim_counts) do + Enum.any?(claim_counts, fn {id, _} -> id == claim.id end) + end end diff --git a/lib/evidence_graph/entities.ex b/lib/evidence_graph/entities.ex index 83a0903..f5ecad6 100644 --- a/lib/evidence_graph/entities.ex +++ b/lib/evidence_graph/entities.ex @@ -11,6 +11,7 @@ defmodule EvidenceGraph.Entities do alias EvidenceGraph.ArangoDB alias EvidenceGraph.Entities.Entity + alias EvidenceGraph.Evidence.Evidence # --------------------------------------------------------------------------- # CRUD @@ -246,33 +247,7 @@ defmodule EvidenceGraph.Entities do {:error, :merge_not_found} {[entry | _rest], remaining_history} -> - # Recreate the source entity from the stored snapshot - restored_attrs = %{ - primary_name: entry["merged_primary_name"], - entity_type: String.to_existing_atom(entry["merged_entity_type"]), - aliases: entry["merged_aliases"] || [], - investigation_id: target.investigation_id, - metadata: Map.put(entry["merged_metadata"] || %{}, "unmerged_from", target_id) - } - - # Remove source aliases from target - aliases_to_remove = - MapSet.new([entry["merged_primary_name"] | entry["merged_aliases"] || []]) - - cleaned_aliases = - Enum.reject(target.aliases, &MapSet.member?(aliases_to_remove, &1)) - - cleaned_metadata = Map.put(target.metadata, "merge_history", remaining_history) - - with {:ok, restored} <- create_entity_with_id(source_id, restored_attrs), - {:ok, _} <- - ArangoDB.update("entities", target_id, %{ - aliases: cleaned_aliases, - metadata: cleaned_metadata, - updated_at: DateTime.to_iso8601(DateTime.utc_now()) - }) do - {:ok, restored} - end + restore_merged_entity(target, target_id, source_id, entry, remaining_history) end end end @@ -353,24 +328,7 @@ defmodule EvidenceGraph.Entities do {ner_string, {:existing, entity}} :none -> - case find_fuzzy_match(ner_string, existing) do - {:ok, entity, similarity} -> - {ner_string, {:suggest_merge, entity, similarity}} - - :none -> - case create_entity(%{ - primary_name: ner_string, - entity_type: :person, - investigation_id: investigation_id, - metadata: %{"source" => "ner_auto"} - }) do - {:ok, new_entity} -> - {ner_string, {:created, new_entity}} - - {:error, reason} -> - {ner_string, {:error, reason}} - end - end + resolve_unmatched_entity(ner_string, existing, investigation_id) end end) end @@ -392,7 +350,7 @@ defmodule EvidenceGraph.Entities do case ArangoDB.query_read(aql, %{entity_id: entity_id}) do {:ok, docs} -> - {:ok, Enum.map(docs, &EvidenceGraph.Evidence.Evidence.from_arango_doc/1)} + {:ok, Enum.map(docs, &Evidence.from_arango_doc/1)} error -> error @@ -500,4 +458,59 @@ defmodule EvidenceGraph.Entities do {:error, changeset} end end + + defp restore_merged_entity(target, target_id, source_id, entry, remaining_history) do + # Recreate the source entity from the stored snapshot + restored_attrs = %{ + primary_name: entry["merged_primary_name"], + entity_type: String.to_existing_atom(entry["merged_entity_type"]), + aliases: entry["merged_aliases"] || [], + investigation_id: target.investigation_id, + metadata: Map.put(entry["merged_metadata"] || %{}, "unmerged_from", target_id) + } + + # Remove source aliases from target + aliases_to_remove = + MapSet.new([entry["merged_primary_name"] | entry["merged_aliases"] || []]) + + cleaned_aliases = + Enum.reject(target.aliases, &MapSet.member?(aliases_to_remove, &1)) + + cleaned_metadata = Map.put(target.metadata, "merge_history", remaining_history) + + with {:ok, restored} <- create_entity_with_id(source_id, restored_attrs), + {:ok, _} <- + ArangoDB.update("entities", target_id, %{ + aliases: cleaned_aliases, + metadata: cleaned_metadata, + updated_at: DateTime.to_iso8601(DateTime.utc_now()) + }) do + {:ok, restored} + end + end + + defp resolve_unmatched_entity(ner_string, existing, investigation_id) do + case find_fuzzy_match(ner_string, existing) do + {:ok, entity, similarity} -> + {ner_string, {:suggest_merge, entity, similarity}} + + :none -> + create_ner_entity(ner_string, investigation_id) + end + end + + defp create_ner_entity(ner_string, investigation_id) do + case create_entity(%{ + primary_name: ner_string, + entity_type: :person, + investigation_id: investigation_id, + metadata: %{"source" => "ner_auto"} + }) do + {:ok, new_entity} -> + {ner_string, {:created, new_entity}} + + {:error, reason} -> + {ner_string, {:error, reason}} + end + end end diff --git a/lib/evidence_graph/evidence.ex b/lib/evidence_graph/evidence.ex index 68b6e7b..5a10b5b 100644 --- a/lib/evidence_graph/evidence.ex +++ b/lib/evidence_graph/evidence.ex @@ -6,6 +6,7 @@ defmodule EvidenceGraph.Evidence do """ alias EvidenceGraph.ArangoDB + alias EvidenceGraph.Claims.Claim alias EvidenceGraph.Evidence.Evidence @doc """ @@ -107,22 +108,7 @@ defmodule EvidenceGraph.Evidence do """ def update_evidence(id, attrs) do with {:ok, evidence} <- get_evidence(id) do - changeset = Evidence.changeset(evidence, attrs) - - if changeset.valid? do - updates = - Ecto.Changeset.apply_changes(changeset) - |> Map.put(:updated_at, DateTime.utc_now()) - |> Evidence.to_arango_doc() - |> Map.drop([:_key, :inserted_at]) - - case ArangoDB.update("evidence", id, updates) do - {:ok, doc} -> {:ok, Evidence.from_arango_doc(doc)} - error -> error - end - else - {:error, changeset} - end + update_evidence_changeset(id, evidence, attrs) end end @@ -262,7 +248,7 @@ defmodule EvidenceGraph.Evidence do claims = Enum.map(results, fn %{"claim" => cl, "relationship" => rel} -> %{ - claim: EvidenceGraph.Claims.Claim.from_arango_doc(cl), + claim: Claim.from_arango_doc(cl), weight: rel["weight"], confidence: rel["confidence"], reasoning: rel["reasoning"] @@ -275,4 +261,23 @@ defmodule EvidenceGraph.Evidence do error end end + + defp update_evidence_changeset(id, evidence, attrs) do + changeset = Evidence.changeset(evidence, attrs) + + if changeset.valid? do + updates = + Ecto.Changeset.apply_changes(changeset) + |> Map.put(:updated_at, DateTime.utc_now()) + |> Evidence.to_arango_doc() + |> Map.drop([:_key, :inserted_at]) + + case ArangoDB.update("evidence", id, updates) do + {:ok, doc} -> {:ok, Evidence.from_arango_doc(doc)} + error -> error + end + else + {:error, changeset} + end + end end diff --git a/lib/evidence_graph/export.ex b/lib/evidence_graph/export.ex index 2ca31a5..c763764 100644 --- a/lib/evidence_graph/export.ex +++ b/lib/evidence_graph/export.ex @@ -395,12 +395,10 @@ defmodule EvidenceGraph.Export do rows = Enum.map(docs, fn doc -> - headers - |> Enum.map(fn header -> + Enum.map_join(headers, ",", fn header -> value = doc[header] csv_escape(value) end) - |> Enum.join(",") end) Enum.join([header_line | rows], "\n") @@ -435,6 +433,7 @@ defmodule EvidenceGraph.Export do value end end + defp csv_escape(value) when is_list(value), do: csv_escape(Enum.join(value, "; ")) defp csv_escape(value) when is_map(value), do: csv_escape(Jason.encode!(value)) defp csv_escape(value), do: to_string(value) diff --git a/lib/evidence_graph/financial.ex b/lib/evidence_graph/financial.ex index f35febb..b8b7fe1 100644 --- a/lib/evidence_graph/financial.ex +++ b/lib/evidence_graph/financial.ex @@ -228,33 +228,7 @@ defmodule EvidenceGraph.Financial do with {:ok, transactions} <- list_transactions(investigation_id, limit: 10_000) do anomalies = Enum.flat_map(transactions, fn txn -> - flags = [] - - # Round number detection - flags = - if is_round_number?(txn.amount) do - [build_anomaly(txn, "round_number", "Exact round amount: #{txn.currency} #{txn.amount}", :medium) | flags] - else - flags - end - - # Structuring detection (just under threshold) - flags = - if txn.amount >= threshold * 0.8 and txn.amount < threshold do - [build_anomaly(txn, "structuring", "Amount #{txn.currency} #{txn.amount} is just under #{threshold} threshold", :high) | flags] - else - flags - end - - # Weekend timing - flags = - if txn.transaction_date && Date.day_of_week(txn.transaction_date) in [6, 7] do - [build_anomaly(txn, "unusual_timing", "Transaction on weekend: #{txn.transaction_date}", :low) | flags] - else - flags - end - - flags + transaction_anomalies(txn, threshold) end) # Rapid succession detection (multiple txns between same parties same day) @@ -333,14 +307,14 @@ defmodule EvidenceGraph.Financial do # Private helpers # --------------------------------------------------------------------------- - defp is_round_number?(amount) when is_float(amount) do + defp round_number?(amount) when is_float(amount) do # Check if the amount is a round number (multiple of 1000 with no cents) rem_cents = :erlang.float_to_binary(amount, decimals: 2) String.ends_with?(rem_cents, "000.00") or String.ends_with?(rem_cents, "500.00") end - defp is_round_number?(_), do: false + defp round_number?(_), do: false defp build_anomaly(txn, type, description, severity) do %{ @@ -376,4 +350,34 @@ defmodule EvidenceGraph.Financial do defp format_date_param(nil), do: nil defp format_date_param(%Date{} = d), do: Date.to_iso8601(d) defp format_date_param(s) when is_binary(s), do: s + + defp transaction_anomalies(txn, threshold) do + flags = [] + + # Round number detection + flags = + if round_number?(txn.amount) do + [build_anomaly(txn, "round_number", "Exact round amount: #{txn.currency} #{txn.amount}", :medium) | flags] + else + flags + end + + # Structuring detection (just under threshold) + flags = + if txn.amount >= threshold * 0.8 and txn.amount < threshold do + [build_anomaly(txn, "structuring", "Amount #{txn.currency} #{txn.amount} is just under #{threshold} threshold", :high) | flags] + else + flags + end + + # Weekend timing + flags = + if txn.transaction_date && Date.day_of_week(txn.transaction_date) in [6, 7] do + [build_anomaly(txn, "unusual_timing", "Transaction on weekend: #{txn.transaction_date}", :low) | flags] + else + flags + end + + flags + end end diff --git a/lib/evidence_graph/investigations.ex b/lib/evidence_graph/investigations.ex index 5f112d5..705c1cc 100644 --- a/lib/evidence_graph/investigations.ex +++ b/lib/evidence_graph/investigations.ex @@ -13,6 +13,7 @@ defmodule EvidenceGraph.Investigations do """ alias EvidenceGraph.ArangoDB + alias EvidenceGraph.Evidence.Evidence @statuses ~w(active archived closed) @@ -193,16 +194,7 @@ defmodule EvidenceGraph.Investigations do case ArangoDB.query_read(aql, %{id: id}) do {:ok, [result]} -> - {:ok, - %{ - evidence_count: result["evidence_count"] || 0, - claim_count: result["claim_count"] || 0, - entity_count: result["entity_count"] || 0, - transaction_count: result["transaction_count"] || 0, - relationship_count: result["relationship_count"] || 0, - avg_prompt_score: result["avg_prompt_score"] || 50.0, - contradiction_count: result["contradiction_count"] || 0 - }} + parse_investigation_stats(result) {:ok, []} -> {:error, :not_found} @@ -301,7 +293,7 @@ defmodule EvidenceGraph.Investigations do parsed = Enum.map(results, fn r -> %{ - evidence: EvidenceGraph.Evidence.Evidence.from_arango_doc(r["evidence"]), + evidence: Evidence.from_arango_doc(r["evidence"]), appears_in_count: r["appears_in_count"] } end) @@ -335,4 +327,17 @@ defmodule EvidenceGraph.Investigations do Valid investigation statuses. """ def valid_statuses, do: @statuses + + defp parse_investigation_stats(result) do + {:ok, + %{ + evidence_count: result["evidence_count"] || 0, + claim_count: result["claim_count"] || 0, + entity_count: result["entity_count"] || 0, + transaction_count: result["transaction_count"] || 0, + relationship_count: result["relationship_count"] || 0, + avg_prompt_score: result["avg_prompt_score"] || 50.0, + contradiction_count: result["contradiction_count"] || 0 + }} + end end diff --git a/lib/evidence_graph/ipfs.ex b/lib/evidence_graph/ipfs.ex index 772f025..2eff3e4 100644 --- a/lib/evidence_graph/ipfs.ex +++ b/lib/evidence_graph/ipfs.ex @@ -70,21 +70,7 @@ defmodule EvidenceGraph.IPFS do if is_nil(cid) or cid == "" do {:ok, %{pinned: false, cid: nil, hash_match: nil}} else - case check_pin_status(cid) do - {:ok, pinned} -> - # If we have both a local file and stored SHA-256, verify integrity - hash_match = - if evidence["local_path"] && evidence["sha256_hash"] do - verify_file_hash(evidence["local_path"], evidence["sha256_hash"]) - else - nil - end - - {:ok, %{pinned: pinned, cid: cid, hash_match: hash_match}} - - {:error, reason} -> - {:error, reason} - end + verify_evidence_pin(evidence, cid) end end end @@ -106,14 +92,7 @@ defmodule EvidenceGraph.IPFS do if is_nil(cid) or cid == "" do :ok else - with :ok <- unpin_hash(cid), - {:ok, _updated} <- - ArangoDB.update("evidence", evidence_id, %{ - ipfs_hash: nil, - updated_at: DateTime.to_iso8601(DateTime.utc_now()) - }) do - :ok - end + remove_evidence_pin(evidence_id, cid) end end end @@ -141,10 +120,7 @@ defmodule EvidenceGraph.IPFS do {:ok, evidence_ids} -> results = Enum.map(evidence_ids, fn eid -> - case pin_evidence(eid) do - {:ok, result} -> {:ok, result} - {:error, reason} -> {:error, %{evidence_id: eid, reason: inspect(reason)}} - end + pin_with_error_context(eid) end) {successes, failures} = @@ -288,4 +264,40 @@ defmodule EvidenceGraph.IPFS do nil end end + + defp verify_evidence_pin(evidence, cid) do + case check_pin_status(cid) do + {:ok, pinned} -> + # If we have both a local file and stored SHA-256, verify integrity + hash_match = + if evidence["local_path"] && evidence["sha256_hash"] do + verify_file_hash(evidence["local_path"], evidence["sha256_hash"]) + else + nil + end + + {:ok, %{pinned: pinned, cid: cid, hash_match: hash_match}} + + {:error, reason} -> + {:error, reason} + end + end + + defp remove_evidence_pin(evidence_id, cid) do + with :ok <- unpin_hash(cid), + {:ok, _updated} <- + ArangoDB.update("evidence", evidence_id, %{ + ipfs_hash: nil, + updated_at: DateTime.to_iso8601(DateTime.utc_now()) + }) do + :ok + end + end + + defp pin_with_error_context(eid) do + case pin_evidence(eid) do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, %{evidence_id: eid, reason: inspect(reason)}} + end + end end diff --git a/lib/evidence_graph/lithoglyph/importer.ex b/lib/evidence_graph/lithoglyph/importer.ex index 0245b04..3683f18 100644 --- a/lib/evidence_graph/lithoglyph/importer.ex +++ b/lib/evidence_graph/lithoglyph/importer.ex @@ -29,11 +29,11 @@ defmodule EvidenceGraph.Lithoglyph.Importer do use GenServer require Logger - alias EvidenceGraph.Lithoglyph.Client, as: LithClient - alias EvidenceGraph.Lithoglyph.NERExtractor alias EvidenceGraph.ArangoDB - alias EvidenceGraph.Evidence alias EvidenceGraph.Entities + alias EvidenceGraph.Evidence + alias EvidenceGraph.Lithoglyph.Client, as: LithClient + alias EvidenceGraph.Lithoglyph.NERExtractor alias EvidenceGraph.Relationships @batch_size 100 @@ -234,24 +234,7 @@ defmodule EvidenceGraph.Lithoglyph.Importer do resolved = Entities.resolve_ner_output(ner_strings, investigation_id) Enum.each(resolved, fn {ner_string, result} -> - case result do - {:existing, entity} -> - create_mentions_edge(evidence.id, entity.id, ner_string, 1.0) - - {:created, entity} -> - create_mentions_edge(evidence.id, entity.id, ner_string, 0.8) - - {:suggest_merge, entity, similarity} -> - # Link to the existing entity but with lower confidence (fuzzy match) - create_mentions_edge(evidence.id, entity.id, ner_string, similarity * 0.9) - Logger.info( - "Fuzzy match for '#{ner_string}' → '#{entity.primary_name}' " <> - "(similarity=#{Float.round(similarity, 3)})" - ) - - {:error, reason} -> - Logger.warning("Failed to resolve entity '#{ner_string}': #{inspect(reason)}") - end + link_resolved_entity(evidence, ner_string, result) end) end rescue @@ -334,15 +317,19 @@ defmodule EvidenceGraph.Lithoglyph.Importer do scores = record["promptScores"] || %{} %{ - provenance: scores["provenance"] || record["prompt_provenance"] || 50, - replicability: scores["replicability"] || record["prompt_replicability"] || 50, - objective: scores["objective"] || record["prompt_objective"] || 50, - methodology: scores["methodology"] || record["prompt_methodology"] || 50, - publication: scores["publication"] || record["prompt_publication"] || 50, - transparency: scores["transparency"] || record["prompt_transparency"] || 50 + provenance: prompt_score(scores["provenance"], record["prompt_provenance"]), + replicability: prompt_score(scores["replicability"], record["prompt_replicability"]), + objective: prompt_score(scores["objective"], record["prompt_objective"]), + methodology: prompt_score(scores["methodology"], record["prompt_methodology"]), + publication: prompt_score(scores["publication"], record["prompt_publication"]), + transparency: prompt_score(scores["transparency"], record["prompt_transparency"]) } end + defp prompt_score(nested, flat) do + nested || flat || 50 + end + defp map_evidence_type("court_filing"), do: :document defp map_evidence_type("deposition"), do: :interview defp map_evidence_type("testimony"), do: :interview @@ -386,4 +373,25 @@ defmodule EvidenceGraph.Lithoglyph.Importer do }} ) end + + defp link_resolved_entity(evidence, ner_string, result) do + case result do + {:existing, entity} -> + create_mentions_edge(evidence.id, entity.id, ner_string, 1.0) + + {:created, entity} -> + create_mentions_edge(evidence.id, entity.id, ner_string, 0.8) + + {:suggest_merge, entity, similarity} -> + # Link to the existing entity but with lower confidence (fuzzy match) + create_mentions_edge(evidence.id, entity.id, ner_string, similarity * 0.9) + Logger.info( + "Fuzzy match for '#{ner_string}' → '#{entity.primary_name}' " <> + "(similarity=#{Float.round(similarity, 3)})" + ) + + {:error, reason} -> + Logger.warning("Failed to resolve entity '#{ner_string}': #{inspect(reason)}") + end + end end diff --git a/lib/evidence_graph/lithoglyph/ner_extractor.ex b/lib/evidence_graph/lithoglyph/ner_extractor.ex index 374890d..2c114b2 100644 --- a/lib/evidence_graph/lithoglyph/ner_extractor.ex +++ b/lib/evidence_graph/lithoglyph/ner_extractor.ex @@ -71,8 +71,7 @@ defmodule EvidenceGraph.Lithoglyph.NERExtractor do candidates |> Enum.map(&String.trim/1) - |> Enum.reject(&(String.length(&1) < 2)) - |> Enum.reject(&stopword?/1) + |> Enum.reject(&(String.length(&1) < 2 or stopword?(&1))) |> Enum.uniq() end diff --git a/lib/evidence_graph/navigation.ex b/lib/evidence_graph/navigation.ex index bef2b34..bc4ef94 100644 --- a/lib/evidence_graph/navigation.ex +++ b/lib/evidence_graph/navigation.ex @@ -107,22 +107,7 @@ defmodule EvidenceGraph.Navigation do """ def update_path(id, attrs) do with {:ok, path} <- get_path(id) do - changeset = Path.changeset(path, attrs) - - if changeset.valid? do - updates = - Ecto.Changeset.apply_changes(changeset) - |> Map.put(:updated_at, DateTime.utc_now()) - |> Path.to_arango_doc() - |> Map.drop([:_key, :inserted_at]) - - case ArangoDB.update("navigation_paths", id, updates) do - {:ok, doc} -> {:ok, Path.from_arango_doc(doc)} - error -> error - end - else - {:error, changeset} - end + update_path_changeset(id, path, attrs) end end @@ -157,26 +142,7 @@ defmodule EvidenceGraph.Navigation do scored_claims = claims |> Enum.map(fn claim -> - {:ok, supporting} = EvidenceGraph.Claims.get_supporting_evidence(claim.id) - - avg_evidence_score = - case supporting do - [] -> - 0.0 - - _ -> - supporting - |> Enum.map(fn %{evidence: ev} -> - PromptScores.calculate_for_audience(ev.prompt_scores, audience_type) - end) - |> Enum.sum() - |> Kernel./(length(supporting)) - end - - claim_score = PromptScores.calculate_for_audience(claim.prompt_scores, audience_type) - combined_score = claim_score * 0.4 + avg_evidence_score * 0.6 - - {claim, combined_score} + score_claim_for_audience(claim, audience_type) end) |> Enum.sort_by(fn {_claim, score} -> score end, :desc) @@ -186,33 +152,8 @@ defmodule EvidenceGraph.Navigation do |> Enum.take(5) |> Enum.with_index() |> Enum.flat_map(fn {{claim, _score}, idx} -> - claim_node = %{ - "entity_id" => claim.id, - "entity_type" => "claim", - "order" => idx * 2 + 1, - "context" => "Key claim for #{audience_type}", - "emphasis" => %{"priority" => "high"} - } - - # Add top supporting evidence - {:ok, supporting} = EvidenceGraph.Claims.get_supporting_evidence(claim.id) - - evidence_node = - if supporting != [] do - top_evidence = hd(supporting).evidence - - %{ - "entity_id" => top_evidence.id, - "entity_type" => "evidence", - "order" => idx * 2 + 2, - "context" => "Supporting evidence", - "emphasis" => %{"highlight_prompt" => true} - } - else - nil - end - - [claim_node, evidence_node] |> Enum.reject(&is_nil/1) + claim_path_nodes(claim, idx, audience_type) + end) entry_point = @@ -245,23 +186,7 @@ defmodule EvidenceGraph.Navigation do path.path_nodes |> Enum.sort_by(& &1["order"]) |> Enum.map(fn node -> - entity = - case node["entity_type"] do - "claim" -> - {:ok, claim} = EvidenceGraph.Claims.get_claim(node["entity_id"]) - {:claim, claim} - - "evidence" -> - {:ok, evidence} = EvidenceGraph.Evidence.get_evidence(node["entity_id"]) - {:evidence, evidence} - end - - %{ - entity: entity, - order: node["order"], - context: node["context"], - emphasis: node["emphasis"] - } + hydrate_path_node(node) end) {:ok, %{path: path, nodes: nodes}} @@ -289,4 +214,96 @@ defmodule EvidenceGraph.Navigation do {:ok, paths} end + + defp update_path_changeset(id, path, attrs) do + changeset = Path.changeset(path, attrs) + + if changeset.valid? do + updates = + Ecto.Changeset.apply_changes(changeset) + |> Map.put(:updated_at, DateTime.utc_now()) + |> Path.to_arango_doc() + |> Map.drop([:_key, :inserted_at]) + + case ArangoDB.update("navigation_paths", id, updates) do + {:ok, doc} -> {:ok, Path.from_arango_doc(doc)} + error -> error + end + else + {:error, changeset} + end + end + + defp score_claim_for_audience(claim, audience_type) do + {:ok, supporting} = EvidenceGraph.Claims.get_supporting_evidence(claim.id) + + avg_evidence_score = + case supporting do + [] -> + 0.0 + + _ -> + supporting + |> Enum.map(fn %{evidence: ev} -> + PromptScores.calculate_for_audience(ev.prompt_scores, audience_type) + end) + |> Enum.sum() + |> Kernel./(length(supporting)) + end + + claim_score = PromptScores.calculate_for_audience(claim.prompt_scores, audience_type) + combined_score = claim_score * 0.4 + avg_evidence_score * 0.6 + + {claim, combined_score} + end + + defp hydrate_path_node(node) do + entity = + case node["entity_type"] do + "claim" -> + {:ok, claim} = EvidenceGraph.Claims.get_claim(node["entity_id"]) + {:claim, claim} + + "evidence" -> + {:ok, evidence} = EvidenceGraph.Evidence.get_evidence(node["entity_id"]) + {:evidence, evidence} + end + + %{ + entity: entity, + order: node["order"], + context: node["context"], + emphasis: node["emphasis"] + } + end + + defp claim_path_nodes(claim, idx, audience_type) do + claim_node = %{ + "entity_id" => claim.id, + "entity_type" => "claim", + "order" => idx * 2 + 1, + "context" => "Key claim for #{audience_type}", + "emphasis" => %{"priority" => "high"} + } + + # Add top supporting evidence + {:ok, supporting} = EvidenceGraph.Claims.get_supporting_evidence(claim.id) + + evidence_node = + if supporting != [] do + top_evidence = hd(supporting).evidence + + %{ + "entity_id" => top_evidence.id, + "entity_type" => "evidence", + "order" => idx * 2 + 2, + "context" => "Supporting evidence", + "emphasis" => %{"highlight_prompt" => true} + } + else + nil + end + + [claim_node, evidence_node] |> Enum.reject(&is_nil/1) + end end diff --git a/lib/evidence_graph/provenance.ex b/lib/evidence_graph/provenance.ex index 4b34f08..6f49cd3 100644 --- a/lib/evidence_graph/provenance.ex +++ b/lib/evidence_graph/provenance.ex @@ -301,11 +301,13 @@ defmodule EvidenceGraph.Provenance do first_seen: action["timestamp"], last_action: action["timestamp"], actor: action["actor"] - }, fn existing -> - %{existing | last_action: action["timestamp"]} - end) + }, &update_last_action(&1, action["timestamp"])) end end) |> Map.values() end + + defp update_last_action(existing, timestamp) do + %{existing | last_action: timestamp} + end end diff --git a/lib/evidence_graph/redactions.ex b/lib/evidence_graph/redactions.ex index 1cfb8c2..950b430 100644 --- a/lib/evidence_graph/redactions.ex +++ b/lib/evidence_graph/redactions.ex @@ -54,17 +54,17 @@ defmodule EvidenceGraph.Redactions do document = %{ _key: "red_" <> Ecto.UUID.generate(), evidence_id: evidence_id, - page: redaction_info[:page] || redaction_info["page"], - position: redaction_info[:position] || redaction_info["position"], + page: redaction_value(redaction_info, :page), + position: redaction_value(redaction_info, :position), suspected_content_type: - redaction_info[:suspected_content_type] || redaction_info["suspected_content_type"], + redaction_value(redaction_info, :suspected_content_type), recovery_method: - redaction_info[:recovery_method] || redaction_info["recovery_method"] || "none", + redaction_value(redaction_info, :recovery_method) || "none", recovery_confidence: - redaction_info[:recovery_confidence] || redaction_info["recovery_confidence"] || 0.0, + redaction_value(redaction_info, :recovery_confidence) || 0.0, recovered_text: - redaction_info[:recovered_text] || redaction_info["recovered_text"], - notes: redaction_info[:notes] || redaction_info["notes"], + redaction_value(redaction_info, :recovered_text), + notes: redaction_value(redaction_info, :notes), created_at: DateTime.to_iso8601(DateTime.utc_now()), updated_at: DateTime.to_iso8601(DateTime.utc_now()) } @@ -205,4 +205,8 @@ defmodule EvidenceGraph.Redactions do ArangoDB.query_read(aql, %{investigation_id: investigation_id}) end + + defp redaction_value(info, key) do + info[key] || info[Atom.to_string(key)] + end end diff --git a/lib/evidence_graph/relationships.ex b/lib/evidence_graph/relationships.ex index a2f7820..5c9a10a 100644 --- a/lib/evidence_graph/relationships.ex +++ b/lib/evidence_graph/relationships.ex @@ -6,6 +6,9 @@ defmodule EvidenceGraph.Relationships do """ alias EvidenceGraph.ArangoDB + alias EvidenceGraph.Claims.Claim + alias EvidenceGraph.Entities.Entity + alias EvidenceGraph.Evidence.Evidence alias EvidenceGraph.Relationships.Relationship @doc """ @@ -56,21 +59,7 @@ defmodule EvidenceGraph.Relationships do """ def update_relationship(id, attrs) do with {:ok, relationship} <- get_relationship(id) do - changeset = Relationship.changeset(relationship, attrs) - - if changeset.valid? do - updates = - Ecto.Changeset.apply_changes(changeset) - |> Relationship.to_arango_doc() - |> Map.drop([:_key, :_from, :_to, :inserted_at]) - - case ArangoDB.update("relationships", id, updates) do - {:ok, doc} -> {:ok, Relationship.from_arango_doc(doc)} - error -> error - end - else - {:error, changeset} - end + update_relationship_changeset(id, relationship, attrs) end end @@ -138,7 +127,7 @@ defmodule EvidenceGraph.Relationships do {:ok, [result]} -> {:ok, %{ - root_claim: EvidenceGraph.Claims.Claim.from_arango_doc(result["root_claim"]), + root_claim: Claim.from_arango_doc(result["root_claim"]), nodes: parse_nodes(result["nodes"]), edges: Enum.map(result["edges"], &Relationship.from_arango_doc/1), max_depth: result["max_depth"] || 0 @@ -156,13 +145,13 @@ defmodule EvidenceGraph.Relationships do Enum.map(nodes, fn node -> cond do String.starts_with?(node["_id"], "claims/") -> - {:claim, EvidenceGraph.Claims.Claim.from_arango_doc(node)} + {:claim, Claim.from_arango_doc(node)} String.starts_with?(node["_id"], "evidence/") -> - {:evidence, EvidenceGraph.Evidence.Evidence.from_arango_doc(node)} + {:evidence, Evidence.from_arango_doc(node)} String.starts_with?(node["_id"], "entities/") -> - {:entity, EvidenceGraph.Entities.Entity.from_arango_doc(node)} + {:entity, Entity.from_arango_doc(node)} true -> {:unknown, node} @@ -259,7 +248,7 @@ defmodule EvidenceGraph.Relationships do contradictions = Enum.map(results, fn result -> %{ - claim: EvidenceGraph.Claims.Claim.from_arango_doc(result["claim"]), + claim: Claim.from_arango_doc(result["claim"]), support_count: result["support_count"], contradiction_count: result["contradiction_count"] } @@ -271,4 +260,22 @@ defmodule EvidenceGraph.Relationships do error end end + + defp update_relationship_changeset(id, relationship, attrs) do + changeset = Relationship.changeset(relationship, attrs) + + if changeset.valid? do + updates = + Ecto.Changeset.apply_changes(changeset) + |> Relationship.to_arango_doc() + |> Map.drop([:_key, :_from, :_to, :inserted_at]) + + case ArangoDB.update("relationships", id, updates) do + {:ok, doc} -> {:ok, Relationship.from_arango_doc(doc)} + error -> error + end + else + {:error, changeset} + end + end end diff --git a/lib/evidence_graph/search.ex b/lib/evidence_graph/search.ex index afdc7f1..44e04f5 100644 --- a/lib/evidence_graph/search.ex +++ b/lib/evidence_graph/search.ex @@ -15,8 +15,8 @@ defmodule EvidenceGraph.Search do alias EvidenceGraph.ArangoDB alias EvidenceGraph.Claims.Claim - alias EvidenceGraph.Evidence.Evidence alias EvidenceGraph.Entities.Entity + alias EvidenceGraph.Evidence.Evidence # --------------------------------------------------------------------------- # Unified search diff --git a/lib/evidence_graph/temporal_credibility.ex b/lib/evidence_graph/temporal_credibility.ex index 067f9c2..9ac7245 100644 --- a/lib/evidence_graph/temporal_credibility.ex +++ b/lib/evidence_graph/temporal_credibility.ex @@ -178,19 +178,7 @@ defmodule EvidenceGraph.TemporalCredibility do # Recalculate credibility for each affected entity results = Enum.map(entity_ids, fn eid -> - case credibility_at(eid, retraction_date) do - {:ok, score} -> - # Update the entity's credibility_score field - ArangoDB.update("entities", eid, %{ - credibility_score: round(score), - updated_at: DateTime.to_iso8601(DateTime.utc_now()) - }) - - {eid, score} - - _ -> - {eid, nil} - end + refresh_entity_credibility(eid, retraction_date) end) {:ok, %{evidence_id: evidence_id, affected_entities: results}} @@ -257,22 +245,10 @@ defmodule EvidenceGraph.TemporalCredibility do total_evidence = stats["total_evidence"] || 0 # Longevity factor: days since first appearance (capped at 365 for normalisation) - longevity_days = - case entity["first_appearance_date"] do - nil -> 0 - date_str -> max(0, Date.diff(Date.utc_today(), Date.from_iso8601!(date_str))) - end - - longevity_factor = min(longevity_days / 365.0, 1.0) + {longevity_days, longevity_factor} = longevity_days(entity) # Consistency factor: corroborations vs contradictions - total_relations = corroborations + contradictions - consistency_factor = - if total_relations > 0 do - corroborations / total_relations - else - 0.5 - end + consistency_factor = source_consistency(corroborations, contradictions) # Retraction impact: proportion of evidence retracted retraction_factor = @@ -388,4 +364,36 @@ defmodule EvidenceGraph.TemporalCredibility do Enum.reverse(timeline) end + + defp refresh_entity_credibility(eid, retraction_date) do + case credibility_at(eid, retraction_date) do + {:ok, score} -> + # Update the entity's credibility_score field + ArangoDB.update("entities", eid, %{ + credibility_score: round(score), + updated_at: DateTime.to_iso8601(DateTime.utc_now()) + }) + + {eid, score} + + _ -> + {eid, nil} + end + end + + defp longevity_days(entity) do + longevity_days = + case entity["first_appearance_date"] do + nil -> 0 + date_str -> max(0, Date.diff(Date.utc_today(), Date.from_iso8601!(date_str))) + end + + longevity_factor = min(longevity_days / 365.0, 1.0) + {longevity_days, longevity_factor} + end + + defp source_consistency(corroborations, contradictions) do + total_relations = corroborations + contradictions + if total_relations > 0, do: corroborations / total_relations, else: 0.5 + end end diff --git a/lib/evidence_graph/testimony.ex b/lib/evidence_graph/testimony.ex index 10e3fc1..2d8cb94 100644 --- a/lib/evidence_graph/testimony.ex +++ b/lib/evidence_graph/testimony.ex @@ -177,9 +177,7 @@ defmodule EvidenceGraph.Testimony do avg_prompt = claims |> Enum.map(fn c -> PromptScores.calculate_overall(c.prompt_scores) end) - |> then(fn scores -> - if length(scores) > 0, do: Enum.sum(scores) / length(scores), else: 50.0 - end) + |> average_prompt_score() self_contradiction_rate = min(self_contradiction_count / max(total, 1), 1.0) corroboration_rate = corroborated_count / max(total, 1) @@ -315,8 +313,8 @@ defmodule EvidenceGraph.Testimony do %{ claim: claim, timestamp: claim.inserted_at, - has_contradiction: length(contradiction_ids) > 0, - has_corroboration: length(corroboration_ids) > 0, + has_contradiction: contradiction_ids != [], + has_corroboration: corroboration_ids != [], contradiction_ids: contradiction_ids, corroboration_ids: corroboration_ids } @@ -333,6 +331,9 @@ defmodule EvidenceGraph.Testimony do # Private helpers # --------------------------------------------------------------------------- + defp average_prompt_score([]), do: 50.0 + defp average_prompt_score(scores), do: Enum.sum(scores) / length(scores) + defp consistency_score(corroborating, contradicting) do total = corroborating + contradicting @@ -346,7 +347,7 @@ defmodule EvidenceGraph.Testimony do defp count_corroborated_claims(claims) do claim_keys = Enum.map(claims, & &1.id) - if length(claim_keys) == 0 do + if claim_keys == [] do 0 else aql = """ diff --git a/lib/evidence_graph/workers/zotero_sync.ex b/lib/evidence_graph/workers/zotero_sync.ex index bf28a87..5c3a7b4 100644 --- a/lib/evidence_graph/workers/zotero_sync.ex +++ b/lib/evidence_graph/workers/zotero_sync.ex @@ -47,10 +47,7 @@ defmodule EvidenceGraph.Workers.ZoteroSync do {:ok, investigation_ids} -> results = Enum.map(investigation_ids, fn inv_id -> - case Sync.incremental_sync(inv_id) do - {:ok, result} -> {:ok, inv_id, result} - {:error, reason} -> {:error, inv_id, reason} - end + sync_investigation(inv_id) end) failed = Enum.filter(results, &match?({:error, _, _}, &1)) @@ -79,4 +76,11 @@ defmodule EvidenceGraph.Workers.ZoteroSync do ArangoDB.query_read(aql, %{}) end + + defp sync_investigation(inv_id) do + case Sync.incremental_sync(inv_id) do + {:ok, result} -> {:ok, inv_id, result} + {:error, reason} -> {:error, inv_id, reason} + end + end end diff --git a/lib/evidence_graph/zotero/sync.ex b/lib/evidence_graph/zotero/sync.ex index ed83c93..330bd09 100644 --- a/lib/evidence_graph/zotero/sync.ex +++ b/lib/evidence_graph/zotero/sync.ex @@ -113,22 +113,7 @@ defmodule EvidenceGraph.Zotero.Sync do version = get_in(evidence.metadata, ["zotero_version"]) || 0 Client.update_item(client, evidence.zotero_key, zotero_data, version) else - case Client.create_item(client, zotero_data) do - {:ok, created} -> - new_key = created["key"] || created["data"]["key"] - new_version = created["version"] || created["data"]["version"] || 0 - - Evidence.update_evidence(evidence_id, %{ - zotero_key: new_key, - zotero_version: new_version, - metadata: Map.merge(evidence.metadata || %{}, %{ - "zotero_version" => new_version - }) - }) - - error -> - error - end + create_zotero_item(client, zotero_data, evidence_id, evidence) end end end @@ -170,4 +155,23 @@ defmodule EvidenceGraph.Zotero.Sync do _ -> nil end end + + defp create_zotero_item(client, zotero_data, evidence_id, evidence) do + case Client.create_item(client, zotero_data) do + {:ok, created} -> + new_key = created["key"] || created["data"]["key"] + new_version = created["version"] || created["data"]["version"] || 0 + + Evidence.update_evidence(evidence_id, %{ + zotero_key: new_key, + zotero_version: new_version, + metadata: Map.merge(evidence.metadata || %{}, %{ + "zotero_version" => new_version + }) + }) + + error -> + error + end + end end diff --git a/lib/evidence_graph_web/channels/investigation_channel.ex b/lib/evidence_graph_web/channels/investigation_channel.ex index 4d62965..0b894da 100644 --- a/lib/evidence_graph_web/channels/investigation_channel.ex +++ b/lib/evidence_graph_web/channels/investigation_channel.ex @@ -29,8 +29,8 @@ defmodule EvidenceGraphWeb.InvestigationChannel do use Phoenix.Channel - alias EvidenceGraph.Collaboration alias EvidenceGraph.Authorization + alias EvidenceGraph.Collaboration @max_annotation_length 10_000 diff --git a/lib/evidence_graph_web/components/core_components.ex b/lib/evidence_graph_web/components/core_components.ex index fe27fc7..6331263 100644 --- a/lib/evidence_graph_web/components/core_components.ex +++ b/lib/evidence_graph_web/components/core_components.ex @@ -10,6 +10,7 @@ defmodule EvidenceGraphWeb.CoreComponents do """ use Phoenix.Component + alias Phoenix.HTML.Form alias Phoenix.LiveView.JS use Gettext, backend: EvidenceGraphWeb.Gettext @@ -332,7 +333,7 @@ defmodule EvidenceGraphWeb.CoreComponents do def input(%{type: "checkbox"} = assigns) do assigns = assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + Form.normalize_value("checkbox", assigns[:value]) end) ~H""" @@ -364,7 +365,7 @@ defmodule EvidenceGraphWeb.CoreComponents do {@rest} > - {Phoenix.HTML.Form.options_for_select(@options, @value)} + {Form.options_for_select(@options, @value)} <.error :for={msg <- @errors}>{msg} @@ -385,7 +386,7 @@ defmodule EvidenceGraphWeb.CoreComponents do @errors != [] && "border-rose-400 focus:border-rose-400" ]} {@rest} - >{Phoenix.HTML.Form.normalize_value("textarea", @value)} + >{Form.normalize_value("textarea", @value)} <.error :for={msg <- @errors}>{msg} """ @@ -399,7 +400,7 @@ defmodule EvidenceGraphWeb.CoreComponents do type={@type} name={@name} id={@id} - value={Phoenix.HTML.Form.normalize_value(@type, @value)} + value={Form.normalize_value(@type, @value)} class={[ "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 dark:bg-zinc-800 dark:text-zinc-100", "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400", diff --git a/lib/evidence_graph_web/controllers/evidence_api_controller.ex b/lib/evidence_graph_web/controllers/evidence_api_controller.ex index 906b6b1..052cc9d 100644 --- a/lib/evidence_graph_web/controllers/evidence_api_controller.ex +++ b/lib/evidence_graph_web/controllers/evidence_api_controller.ex @@ -16,8 +16,9 @@ defmodule EvidenceGraphWeb.EvidenceApiController do use EvidenceGraphWeb, :controller - alias EvidenceGraph.Evidence alias EvidenceGraph.ArangoDB + alias EvidenceGraph.Evidence + alias EvidenceGraph.Lithoglyph.Importer alias EvidenceGraph.Zotero.Sync action_fallback :handle_fallback @@ -241,7 +242,7 @@ defmodule EvidenceGraphWeb.EvidenceApiController do def lithoglyph_import(conn, %{"investigation_id" => investigation_id} = params) do run_id = params["run_id"] || "import-#{System.unique_integer([:positive])}" - EvidenceGraph.Lithoglyph.Importer.run_import(investigation_id, run_id: run_id) + Importer.run_import(investigation_id, run_id: run_id) conn |> put_status(:accepted) @@ -280,7 +281,7 @@ defmodule EvidenceGraphWeb.EvidenceApiController do } """ def lithoglyph_import_status(conn, _params) do - status = EvidenceGraph.Lithoglyph.Importer.status() + status = Importer.status() conn |> put_status(:ok) diff --git a/lib/evidence_graph_web/live/graph_live.ex b/lib/evidence_graph_web/live/graph_live.ex index 04641f2..09c1b73 100644 --- a/lib/evidence_graph_web/live/graph_live.ex +++ b/lib/evidence_graph_web/live/graph_live.ex @@ -16,8 +16,8 @@ defmodule EvidenceGraphWeb.GraphLive do alias EvidenceGraph.Claims alias EvidenceGraph.Evidence - alias EvidenceGraph.Relationships alias EvidenceGraph.PromptScores + alias EvidenceGraph.Relationships @audience_types [:researcher, :policymaker, :skeptic, :activist, :affected_person, :journalist] @@ -191,22 +191,7 @@ defmodule EvidenceGraphWeb.GraphLive do # Collect relationships for all claims links = claims - |> Enum.flat_map(fn claim -> - case Relationships.get_node_relationships(claim.id, :claim) do - {:ok, rels} -> - Enum.map(rels, fn rel -> - %{ - source: rel.from_id, - target: rel.to_id, - relationship: to_string(rel.relationship_type), - weight: rel.weight - } - end) - - _ -> - [] - end - end) + |> Enum.flat_map(&claim_links/1) |> Enum.uniq_by(fn l -> {l.source, l.target} end) %{nodes: nodes, links: links} @@ -255,4 +240,21 @@ defmodule EvidenceGraphWeb.GraphLive do _ -> [] end end + + defp claim_links(claim) do + case Relationships.get_node_relationships(claim.id, :claim) do + {:ok, rels} -> + Enum.map(rels, fn rel -> + %{ + source: rel.from_id, + target: rel.to_id, + relationship: to_string(rel.relationship_type), + weight: rel.weight + } + end) + + _ -> + [] + end + end end diff --git a/lib/evidence_graph_web/live/navigation_live.ex b/lib/evidence_graph_web/live/navigation_live.ex index 4468c1b..bf4493f 100644 --- a/lib/evidence_graph_web/live/navigation_live.ex +++ b/lib/evidence_graph_web/live/navigation_live.ex @@ -213,18 +213,12 @@ defmodule EvidenceGraphWeb.NavigationLive do path_data = case Navigation.list_paths(investigation_id, audience_type: audience) do {:ok, [path | _]} -> - case Navigation.get_path_with_nodes(path.id) do - {:ok, data} -> data - _ -> nil - end + path_with_nodes(path) {:ok, []} -> case Navigation.auto_generate_path(investigation_id, audience) do {:ok, path} -> - case Navigation.get_path_with_nodes(path.id) do - {:ok, data} -> data - _ -> nil - end + path_with_nodes(path) _ -> nil @@ -237,6 +231,13 @@ defmodule EvidenceGraphWeb.NavigationLive do assign(socket, path_data: path_data, loading: false) end + defp path_with_nodes(path) do + case Navigation.get_path_with_nodes(path.id) do + {:ok, data} -> data + _ -> nil + end + end + defp progress_percent(current, total) when total > 0 do Float.round((current + 1) / total * 100, 0) end diff --git a/lib/evidence_graph_web/live/timeline_live.ex b/lib/evidence_graph_web/live/timeline_live.ex index ce6b0d2..47cc810 100644 --- a/lib/evidence_graph_web/live/timeline_live.ex +++ b/lib/evidence_graph_web/live/timeline_live.ex @@ -267,20 +267,7 @@ defmodule EvidenceGraphWeb.TimelineLive do case Evidence.list_evidence(investigation_id, limit: 10_000) do {:ok, items} -> Enum.flat_map(items, fn e -> - date = extract_evidence_date(e) - - if date do - [%{ - id: e.id, - type: "evidence", - label: e.title, - date: date, - prompt_score: PromptScores.calculate_overall(e.prompt_scores), - entity_ids: [] - }] - else - [] - end + evidence_event(e) end) _ -> @@ -402,4 +389,20 @@ defmodule EvidenceGraphWeb.TimelineLive do defp format_date_input(nil), do: "" defp format_date_input(%Date{} = d), do: Date.to_iso8601(d) + defp evidence_event(e) do + date = extract_evidence_date(e) + + if date do + [%{ + id: e.id, + type: "evidence", + label: e.title, + date: date, + prompt_score: PromptScores.calculate_overall(e.prompt_scores), + entity_ids: [] + }] + else + [] + end + end end diff --git a/lib/evidence_graph_web/plugs/api_key_auth.ex b/lib/evidence_graph_web/plugs/api_key_auth.ex index db2fa8b..8efd1f4 100644 --- a/lib/evidence_graph_web/plugs/api_key_auth.ex +++ b/lib/evidence_graph_web/plugs/api_key_auth.ex @@ -68,34 +68,7 @@ defmodule EvidenceGraphWeb.Plugs.ApiKeyAuth do defp validate_and_authorize(conn, key, required_scope) do case ApiKeys.validate_api_key(key) do {:ok, api_key_doc} -> - # Check scope if required - if required_scope && !has_scope?(api_key_doc, required_scope) do - conn - |> put_status(:forbidden) - |> Phoenix.Controller.json(%{ - errors: %{detail: "Insufficient scope. Required: #{required_scope}"} - }) - |> halt() - else - # Check rate limit - case check_rate_limit(api_key_doc["_key"]) do - :ok -> - conn - |> assign(:api_key, api_key_doc) - |> assign(:api_key_user_id, api_key_doc["user_id"]) - - :rate_limited -> - conn - |> put_status(:too_many_requests) - |> put_resp_header("retry-after", to_string(@rate_window_seconds)) - |> Phoenix.Controller.json(%{ - errors: %{ - detail: "Rate limit exceeded. Maximum #{@rate_limit} requests per hour." - } - }) - |> halt() - end - end + authorize_key(conn, api_key_doc, required_scope) {:error, :not_found} -> conn @@ -156,4 +129,35 @@ defmodule EvidenceGraphWeb.Plugs.ApiKeyAuth do :ok end end + + defp authorize_key(conn, api_key_doc, required_scope) do + # Check scope if required + if required_scope && !has_scope?(api_key_doc, required_scope) do + conn + |> put_status(:forbidden) + |> Phoenix.Controller.json(%{ + errors: %{detail: "Insufficient scope. Required: #{required_scope}"} + }) + |> halt() + else + # Check rate limit + case check_rate_limit(api_key_doc["_key"]) do + :ok -> + conn + |> assign(:api_key, api_key_doc) + |> assign(:api_key_user_id, api_key_doc["user_id"]) + + :rate_limited -> + conn + |> put_status(:too_many_requests) + |> put_resp_header("retry-after", to_string(@rate_window_seconds)) + |> Phoenix.Controller.json(%{ + errors: %{ + detail: "Rate limit exceeded. Maximum #{@rate_limit} requests per hour." + } + }) + |> halt() + end + end + end end diff --git a/lib/evidence_graph_web/plugs/authorize.ex b/lib/evidence_graph_web/plugs/authorize.ex index 43a6e18..ab307e8 100644 --- a/lib/evidence_graph_web/plugs/authorize.ex +++ b/lib/evidence_graph_web/plugs/authorize.ex @@ -95,7 +95,7 @@ defmodule EvidenceGraphWeb.Plugs.Authorize do defp extract_user_id(_), do: nil defp halt_unauthorized(conn) do - if is_api_request?(conn) do + if api_request?(conn) do conn |> put_status(401) |> json(%{error: "unauthorized", message: "Authentication required"}) @@ -109,7 +109,7 @@ defmodule EvidenceGraphWeb.Plugs.Authorize do end defp halt_forbidden(conn) do - if is_api_request?(conn) do + if api_request?(conn) do conn |> put_status(403) |> json(%{error: "forbidden", message: "You do not have access to this investigation"}) @@ -122,7 +122,7 @@ defmodule EvidenceGraphWeb.Plugs.Authorize do end end - defp is_api_request?(conn) do + defp api_request?(conn) do Enum.any?(get_req_header(conn, "accept"), &String.contains?(&1, "json")) or String.starts_with?(conn.request_path, "/api") end diff --git a/lib/evidence_graph_web/schema.ex b/lib/evidence_graph_web/schema.ex index 960049b..5645ee0 100644 --- a/lib/evidence_graph_web/schema.ex +++ b/lib/evidence_graph_web/schema.ex @@ -2,6 +2,8 @@ # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema do + @moduledoc "GraphQL queries and mutations for the evidence graph." + use Absinthe.Schema import_types(Absinthe.Type.Custom) @@ -18,9 +20,16 @@ defmodule EvidenceGraphWeb.Schema do import_types(EvidenceGraphWeb.Schema.Types.ContradictionTypes) import_types(EvidenceGraphWeb.Schema.Types.AuthorizationTypes) - alias EvidenceGraph.{Claims, Evidence, Navigation, Relationships} - alias EvidenceGraph.{Investigations, Testimony, Contradictions, Search, Authorization} + alias EvidenceGraph.Authorization + alias EvidenceGraph.Claims + alias EvidenceGraph.Contradictions alias EvidenceGraph.Entities + alias EvidenceGraph.Evidence + alias EvidenceGraph.Investigations + alias EvidenceGraph.Navigation + alias EvidenceGraph.Relationships + alias EvidenceGraph.Search + alias EvidenceGraph.Testimony alias EvidenceGraphWeb.Schema.Resolvers.EntityResolver alias EvidenceGraphWeb.Schema.Resolvers.FinancialResolver diff --git a/lib/evidence_graph_web/schema/resolvers/entity_resolver.ex b/lib/evidence_graph_web/schema/resolvers/entity_resolver.ex index cf383c3..f9a2a74 100644 --- a/lib/evidence_graph_web/schema/resolvers/entity_resolver.ex +++ b/lib/evidence_graph_web/schema/resolvers/entity_resolver.ex @@ -8,8 +8,8 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.EntityResolver do user's access to the investigation that owns the entity. """ - alias EvidenceGraph.Entities alias EvidenceGraph.Authorization + alias EvidenceGraph.Entities import EvidenceGraphWeb.Schema, only: [require_auth: 1] # --------------------------------------------------------------------------- @@ -39,16 +39,20 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.EntityResolver do @doc "Search entities by name/alias." def search_entities(%{query: query} = args, resolution) do with {:ok, user_id} <- require_auth(resolution) do - if inv_id = args[:investigation_id] do - with :ok <- Authorization.check_access(inv_id, user_id, :view) do - Entities.search_entities(query, inv_id) - end - else - Entities.search_entities(query, nil) - end + search_entities_for_user(query, args[:investigation_id], user_id) end end + defp search_entities_for_user(query, inv_id, user_id) when inv_id not in [nil, false] do + with :ok <- Authorization.check_access(inv_id, user_id, :view) do + Entities.search_entities(query, inv_id) + end + end + + defp search_entities_for_user(query, _inv_id, _user_id) do + Entities.search_entities(query, nil) + end + # --------------------------------------------------------------------------- # Mutations # --------------------------------------------------------------------------- @@ -104,22 +108,26 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.EntityResolver do results = Entities.resolve_ner_output(ner_strings, inv_id) |> Enum.map(fn {ner_string, result} -> - case result do - {:existing, entity} -> - %{ner_string: ner_string, status: :existing, entity: entity, similarity: nil} + ner_result(ner_string, result) + end) - {:suggest_merge, entity, similarity} -> - %{ner_string: ner_string, status: :suggest_merge, entity: entity, similarity: similarity} + {:ok, results} + end + end - {:created, entity} -> - %{ner_string: ner_string, status: :created, entity: entity, similarity: nil} + defp ner_result(ner_string, result) do + case result do + {:existing, entity} -> + %{ner_string: ner_string, status: :existing, entity: entity, similarity: nil} - {:error, _reason} -> - %{ner_string: ner_string, status: :error, entity: nil, similarity: nil} - end - end) + {:suggest_merge, entity, similarity} -> + %{ner_string: ner_string, status: :suggest_merge, entity: entity, similarity: similarity} - {:ok, results} + {:created, entity} -> + %{ner_string: ner_string, status: :created, entity: entity, similarity: nil} + + {:error, _reason} -> + %{ner_string: ner_string, status: :error, entity: nil, similarity: nil} end end end diff --git a/lib/evidence_graph_web/schema/resolvers/financial_resolver.ex b/lib/evidence_graph_web/schema/resolvers/financial_resolver.ex index 5b51444..eaaf6c5 100644 --- a/lib/evidence_graph_web/schema/resolvers/financial_resolver.ex +++ b/lib/evidence_graph_web/schema/resolvers/financial_resolver.ex @@ -8,9 +8,9 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.FinancialResolver do user's access to the investigation that owns the financial data. """ - alias EvidenceGraph.Financial - alias EvidenceGraph.Entities alias EvidenceGraph.Authorization + alias EvidenceGraph.Entities + alias EvidenceGraph.Financial import EvidenceGraphWeb.Schema, only: [require_auth: 1] # --------------------------------------------------------------------------- @@ -36,49 +36,26 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.FinancialResolver do @doc "Follow-the-money graph traversal from an entity." def transaction_chain(%{entity_id: entity_id} = args, resolution) do - with {:ok, user_id} <- require_auth(resolution) do - # If investigation_id is provided, check access to it directly. - # Otherwise, look up the entity's investigation_id. - inv_id = - if args[:investigation_id] do - args[:investigation_id] - else - case Entities.get_entity(entity_id) do - {:ok, entity} -> entity.investigation_id - _ -> nil - end - end - - if inv_id do - with :ok <- Authorization.check_access(inv_id, user_id, :view) do - depth = args[:depth] || 3 - opts = if args[:investigation_id], do: [investigation_id: args[:investigation_id]], else: [] - Financial.transaction_chain(entity_id, depth, opts) - end - else - {:error, :not_found} - end + with {:ok, user_id} <- require_auth(resolution), + {:ok, inv_id} <- chain_investigation(entity_id, args), + :ok <- Authorization.check_access(inv_id, user_id, :view) do + depth = args[:depth] || 3 + opts = if args[:investigation_id], do: [investigation_id: args[:investigation_id]], else: [] + Financial.transaction_chain(entity_id, depth, opts) end end @doc "Aggregate total flow between two entities." def total_flow(%{from_id: from_id, to_id: to_id} = args, resolution) do - with {:ok, user_id} <- require_auth(resolution) do - # Look up the source entity to determine the investigation - case Entities.get_entity(from_id) do - {:ok, entity} -> - with :ok <- Authorization.check_access(entity.investigation_id, user_id, :view) do - opts = - [] - |> maybe_put(:start_date, args[:start_date]) - |> maybe_put(:end_date, args[:end_date]) - - Financial.total_flow(from_id, to_id, opts) - end - - error -> - error - end + with {:ok, user_id} <- require_auth(resolution), + {:ok, entity} <- Entities.get_entity(from_id), + :ok <- Authorization.check_access(entity.investigation_id, user_id, :view) do + opts = + [] + |> maybe_put(:start_date, args[:start_date]) + |> maybe_put(:end_date, args[:end_date]) + + Financial.total_flow(from_id, to_id, opts) end end @@ -116,4 +93,16 @@ defmodule EvidenceGraphWeb.Schema.Resolvers.FinancialResolver do defp maybe_put(opts, _key, nil), do: opts defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp chain_investigation(entity_id, args) do + inv_id = args[:investigation_id] || entity_investigation(entity_id) + if inv_id, do: {:ok, inv_id}, else: {:error, :not_found} + end + + defp entity_investigation(entity_id) do + case Entities.get_entity(entity_id) do + {:ok, entity} -> entity.investigation_id + _ -> nil + end + end end diff --git a/lib/evidence_graph_web/schema/types/claim_types.ex b/lib/evidence_graph_web/schema/types/claim_types.ex index cc16cb2..5eefe42 100644 --- a/lib/evidence_graph_web/schema/types/claim_types.ex +++ b/lib/evidence_graph_web/schema/types/claim_types.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema.Types.ClaimTypes do + @moduledoc "GraphQL types for claims." + use Absinthe.Schema.Notation @desc "A claim in an investigation" object :claim do diff --git a/lib/evidence_graph_web/schema/types/evidence_types.ex b/lib/evidence_graph_web/schema/types/evidence_types.ex index a4ac24c..1aa4117 100644 --- a/lib/evidence_graph_web/schema/types/evidence_types.ex +++ b/lib/evidence_graph_web/schema/types/evidence_types.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema.Types.EvidenceTypes do + @moduledoc "GraphQL types for evidence." + use Absinthe.Schema.Notation @desc "Evidence supporting or contradicting claims" diff --git a/lib/evidence_graph_web/schema/types/navigation_types.ex b/lib/evidence_graph_web/schema/types/navigation_types.ex index 9acff32..86445bd 100644 --- a/lib/evidence_graph_web/schema/types/navigation_types.ex +++ b/lib/evidence_graph_web/schema/types/navigation_types.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema.Types.NavigationTypes do + @moduledoc "GraphQL types for audience navigation paths." + use Absinthe.Schema.Notation @desc "Navigation path for specific audience" diff --git a/lib/evidence_graph_web/schema/types/prompt_scores_types.ex b/lib/evidence_graph_web/schema/types/prompt_scores_types.ex index 3e79f01..8ed0e43 100644 --- a/lib/evidence_graph_web/schema/types/prompt_scores_types.ex +++ b/lib/evidence_graph_web/schema/types/prompt_scores_types.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema.Types.PromptScoresTypes do + @moduledoc "GraphQL types for the six PROMPT scoring dimensions." + use Absinthe.Schema.Notation @desc "PROMPT epistemological scoring (6 dimensions)" diff --git a/lib/evidence_graph_web/schema/types/relationship_types.ex b/lib/evidence_graph_web/schema/types/relationship_types.ex index e4da5f3..2c2ecc0 100644 --- a/lib/evidence_graph_web/schema/types/relationship_types.ex +++ b/lib/evidence_graph_web/schema/types/relationship_types.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.Schema.Types.RelationshipTypes do + @moduledoc "GraphQL types for relationships between claims and evidence." + use Absinthe.Schema.Notation @desc "Relationship between claims and evidence" diff --git a/lib/evidence_graph_web/user_auth.ex b/lib/evidence_graph_web/user_auth.ex index 0286d58..4b66c6d 100644 --- a/lib/evidence_graph_web/user_auth.ex +++ b/lib/evidence_graph_web/user_auth.ex @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) defmodule EvidenceGraphWeb.UserAuth do + @moduledoc "Authentication helpers for browser sessions and LiveView mounts." + use EvidenceGraphWeb, :verified_routes import Plug.Conn diff --git a/lib/mix/tasks/arango.reset.ex b/lib/mix/tasks/arango.reset.ex index 4eab589..5479355 100644 --- a/lib/mix/tasks/arango.reset.ex +++ b/lib/mix/tasks/arango.reset.ex @@ -13,6 +13,8 @@ defmodule Mix.Tasks.Arango.Reset do use Mix.Task + alias Mix.Tasks.Arango.Setup + @shortdoc "Drop and recreate ArangoDB database" @impl Mix.Task @@ -57,7 +59,7 @@ defmodule Mix.Tasks.Arango.Reset do GenServer.stop(conn) # Re-create - Mix.Tasks.Arango.Setup.run(args) + Setup.run(args) end defp extract_username(opts) do diff --git a/src/abi/Layout.idr b/src/abi/Layout.idr index 135e93a..5756b3a 100644 --- a/src/abi/Layout.idr +++ b/src/abi/Layout.idr @@ -1,10 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 ||| Memory Layout Documentation for Evidence Graph ||| ||| Documents memory layout for C-compatible Evidence Graph structs. ||| Actual size/alignment verification happens in Zig via comptime assertions ||| (see ffi/zig/src/main.zig). ||| -||| SPDX-License-Identifier: MPL-2.0 ||| Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) module EvidenceGraph.ABI.Layout diff --git a/test/evidence_graph/context_updates_test.exs b/test/evidence_graph/context_updates_test.exs new file mode 100644 index 0000000..5e19863 --- /dev/null +++ b/test/evidence_graph/context_updates_test.exs @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MPL-2.0 +defmodule EvidenceGraph.ContextUpdatesTest do + use ExUnit.Case, async: false + + alias EvidenceGraph.ArangoDB + alias EvidenceGraph.Claims + alias EvidenceGraph.Evidence + alias EvidenceGraph.Fixtures + alias EvidenceGraph.Navigation + alias EvidenceGraph.Relationships + + for {context, collection, create, update, get, fixture, field, invalid, valid} <- [ + {Claims, "claims", :create_claim, :update_claim, :get_claim, :valid_claim_attrs, :text, + "", "Updated investigative claim"}, + {Evidence, "evidence", :create_evidence, :update_evidence, :get_evidence, + :valid_evidence_attrs, :title, "", "Updated evidence title"}, + {Relationships, "relationships", :create_relationship, :update_relationship, + :get_relationship, :valid_relationship_attrs, :weight, -2.0, 0.5}, + {Navigation, "navigation_paths", :create_path, :update_path, :get_path, :valid_path_attrs, + :name, "", "Updated navigation path"} + ] do + @context context + @collection collection + @create create + @update update + @get get + @fixture fixture + @field field + @invalid invalid + @valid valid + + test "#{collection} updates preserve records on validation errors and accept valid changes" do + attrs = apply(Fixtures, @fixture, []) + assert {:ok, original} = apply(@context, @create, [attrs]) + on_exit(fn -> ArangoDB.delete(@collection, original.id) end) + + assert {:error, %Ecto.Changeset{valid?: false} = changeset} = + apply(@context, @update, [original.id, %{@field => @invalid}]) + + assert Keyword.has_key?(changeset.errors, @field) + assert {:ok, ^original} = apply(@context, @get, [original.id]) + + assert {:ok, updated} = apply(@context, @update, [original.id, %{@field => @valid}]) + assert Map.fetch!(updated, @field) == @valid + assert updated.id == original.id + assert updated.inserted_at == original.inserted_at + assert {:ok, ^updated} = apply(@context, @get, [original.id]) + end + + test "#{collection} updates return not_found for a missing record" do + assert {:error, :not_found} = + apply(@context, @update, ["missing_#{Ecto.UUID.generate()}", %{@field => @valid}]) + end + end +end diff --git a/test/evidence_graph_web/controllers/evidence_api_controller_test.exs b/test/evidence_graph_web/controllers/evidence_api_controller_test.exs index ee59a2c..5244a80 100644 --- a/test/evidence_graph_web/controllers/evidence_api_controller_test.exs +++ b/test/evidence_graph_web/controllers/evidence_api_controller_test.exs @@ -20,7 +20,7 @@ defmodule EvidenceGraphWeb.EvidenceApiControllerTest do @valid_zotero_item %{ "key" => "ZTEST001", "version" => 7, - "library" => %{"id" => 12345}, + "library" => %{"id" => 12_345}, "data" => %{ "key" => "ZTEST001", "version" => 7, diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 0064774..5d10676 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -16,6 +16,9 @@ defmodule EvidenceGraphWeb.ConnCase do use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL.Sandbox + alias EvidenceGraph.Accounts.Scope + using do quote do # The default endpoint for testing @@ -32,8 +35,8 @@ defmodule EvidenceGraphWeb.ConnCase do end setup _tags do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(EvidenceGraph.Repo, caller: self()) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + pid = Sandbox.start_owner!(EvidenceGraph.Repo, caller: self()) + on_exit(fn -> Sandbox.stop_owner(pid) end) {:ok, conn: Phoenix.ConnTest.build_conn()} end @@ -48,7 +51,7 @@ defmodule EvidenceGraphWeb.ConnCase do """ def register_and_log_in_user(%{conn: conn} = context) do user = EvidenceGraph.AccountsFixtures.user_fixture() - scope = EvidenceGraph.Accounts.Scope.for_user(user) + scope = Scope.for_user(user) opts = context diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 3046fd0..286d625 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -15,6 +15,8 @@ defmodule EvidenceGraph.DataCase do use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL.Sandbox + using do quote do alias EvidenceGraph.Repo @@ -35,8 +37,8 @@ defmodule EvidenceGraph.DataCase do Sets up the sandbox based on the test tags. """ def setup_sandbox(tags) do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(EvidenceGraph.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + pid = Sandbox.start_owner!(EvidenceGraph.Repo, shared: not tags[:async]) + on_exit(fn -> Sandbox.stop_owner(pid) end) end @doc """