From 1c412b6962bfe4102253c916e3c8a50be63ffc08 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 10 Jun 2026 23:04:27 -0500 Subject: [PATCH 01/13] One-click collection-intelligence setup (composite bundle) New corpora landed with unreadable document indexes (raw import metadata as descriptions, 0% summary coverage) because nothing composed the existing enrichment machinery at corpus setup: the reference-web CTA installed only the deterministic half, and the LLM action templates (descriptions, summaries) sat in the Action Library waiting to be manually added and batch-run. CorpusIntelligenceSetupService composes the default bundle in one idempotent call: installs the reference-enrichment add_document action and starts the first weave, clones the Document Description Updater + Document Summary Generator templates, and batch-runs each over every document already present. Exposed as the setupCorpusIntelligence mutation + corpusIntelligenceSetupStatus query; createCorpus now returns objId so the New Corpus modal's default-on opt-in can chain setup after creation. An IntelligenceSetupBanner inside IntelligencePanel offers setup on both the intelligence overview and the insight-panel CAML embed, and disappears once the bundle is installed. Live-proven on the dev stack: status flips not-set-up -> fully-set-up, the weave starts, and the description/summary agents drain over existing docs (the Fervo demo corpus index is now human-readable). --- .../corpus-intelligence-setup.added.md | 31 ++ config/graphql/corpus_mutations.py | 45 +++ config/graphql/corpus_queries.py | 28 ++ config/graphql/corpus_types.py | 61 ++++ config/graphql/mutations.py | 2 + .../intelligence/IntelligencePanel.tsx | 5 + .../intelligence/IntelligenceSetupBanner.tsx | 191 ++++++++++ .../src/components/corpuses/CorpusModal.tsx | 24 ++ frontend/src/graphql/mutations.ts | 60 +++ frontend/src/graphql/queries.ts | 29 ++ frontend/src/views/Corpuses.tsx | 22 ++ frontend/tests/IntelligenceSetupBanner.ct.tsx | 142 ++++++++ .../constants/corpus_actions.py | 19 + .../corpuses/services/__init__.py | 8 + .../corpuses/services/intelligence_setup.py | 342 ++++++++++++++++++ .../tests/test_intelligence_setup.py | 286 +++++++++++++++ 16 files changed, 1295 insertions(+) create mode 100644 changelog.d/corpus-intelligence-setup.added.md create mode 100644 frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx create mode 100644 frontend/tests/IntelligenceSetupBanner.ct.tsx create mode 100644 opencontractserver/corpuses/services/intelligence_setup.py create mode 100644 opencontractserver/tests/test_intelligence_setup.py diff --git a/changelog.d/corpus-intelligence-setup.added.md b/changelog.d/corpus-intelligence-setup.added.md new file mode 100644 index 000000000..36329c50e --- /dev/null +++ b/changelog.d/corpus-intelligence-setup.added.md @@ -0,0 +1,31 @@ +- **One-click collection-intelligence setup** — the orchestration layer the + enrichment pieces were missing: nothing previously composed the + deterministic reference web with the LLM document enrichment at corpus + setup, so new corpora landed with unreadable document indexes (raw import + metadata as descriptions, 0% summary coverage) until each action was + manually added from the Action Library and batch-run. + - `CorpusIntelligenceSetupService` + (`opencontractserver/corpuses/services/intelligence_setup.py`): + idempotent composite that installs the reference-enrichment + `add_document` action + starts the first weave, clones the + *Document Description Updater* and *Document Summary Generator* + templates (bundle pinned in + `opencontractserver/constants/corpus_actions.py` + `INTELLIGENCE_SETUP_TEMPLATE_NAMES`), and batch-runs each over every + document already in the corpus. Re-running converges: existing action + rows are reused, already-run documents are skipped, an in-flight + reference analysis is not duplicated. + - GraphQL: `setupCorpusIntelligence` mutation + + `corpusIntelligenceSetupStatus` query + (`config/graphql/corpus_mutations.py`, `corpus_queries.py`, + `corpus_types.py`); `createCorpus` now returns `objId` so follow-up + mutations can chain off creation. + - Frontend: `IntelligenceSetupBanner` + (`frontend/src/components/corpuses/CorpusHome/intelligence/`) renders a + setup CTA inside `IntelligencePanel` (so both the intelligence overview + and the `insight-panel` CAML embed surface it) and hides once the bundle + is installed; the New Corpus modal gains a default-on "Set up collection + intelligence" opt-in that chains the mutation after creation + (`CorpusModal.tsx`, `views/Corpuses.tsx`). + - Tests: `opencontractserver/tests/test_intelligence_setup.py` (service + + GraphQL), `frontend/tests/IntelligenceSetupBanner.ct.tsx`. diff --git a/config/graphql/corpus_mutations.py b/config/graphql/corpus_mutations.py index c94f45d14..2fb94e39f 100644 --- a/config/graphql/corpus_mutations.py +++ b/config/graphql/corpus_mutations.py @@ -14,6 +14,7 @@ from graphql_relay import from_global_id, to_global_id from config.graphql.base import DRFDeletion, DRFMutation +from config.graphql.corpus_types import CorpusIntelligenceSetupSummaryType from config.graphql.graphene_types import ( CorpusActionExecutionType, CorpusActionType, @@ -1655,6 +1656,50 @@ def mutate(root, info, template_id: str, corpus_id: str) -> "AddTemplateToCorpus ) +class SetupCorpusIntelligence(graphene.Mutation): + """One-click collection-intelligence setup. + + Composes the default enrichment bundle in a single idempotent call: + installs the reference-enrichment analyzer as an ``add_document`` action + and starts the first weave (deterministic), then clones the description + + summary action templates and batch-runs each over every document already + in the corpus (LLM). Safe to repeat — every step skips work that already + exists. Requires UPDATE permission on the corpus. + """ + + class Arguments: + corpus_id = graphene.ID( + required=True, description="ID of the corpus to set up." + ) + + ok = graphene.Boolean() + message = graphene.String() + summary = graphene.Field(CorpusIntelligenceSetupSummaryType) + + @login_required + def mutate(root, info, corpus_id: str) -> "SetupCorpusIntelligence": + from opencontractserver.corpuses.services import ( + CorpusIntelligenceSetupService, + ) + + failure_msg = "Corpus not found or you don't have permission." + try: + corpus_pk = int(from_global_id(corpus_id)[1]) + except Exception: + return SetupCorpusIntelligence(ok=False, message=failure_msg, summary=None) + + result = CorpusIntelligenceSetupService.setup( + info.context.user, corpus_pk, request=info.context + ) + if not result.ok: + return SetupCorpusIntelligence(ok=False, message=result.error, summary=None) + return SetupCorpusIntelligence( + ok=True, + message="Collection intelligence setup started.", + summary=result.value, + ) + + class ToggleCorpusMemory(graphene.Mutation): """ Toggle the agent memory system on/off for a corpus. diff --git a/config/graphql/corpus_queries.py b/config/graphql/corpus_queries.py index 30033a969..7188ce997 100644 --- a/config/graphql/corpus_queries.py +++ b/config/graphql/corpus_queries.py @@ -17,6 +17,7 @@ CorpusDocumentGraphNodeType, CorpusDocumentGraphType, CorpusIntelligenceAggregatesType, + CorpusIntelligenceSetupStatusType, LabelDistributionEntryType, ) from config.graphql.filters import CorpusCategoryFilter, CorpusFilter @@ -304,6 +305,33 @@ def resolve_deleted_documents_in_corpus(self, info, corpus_id) -> Any: ) # CORPUS STATS RESOLVERS ##################################### + corpus_intelligence_setup_status = graphene.Field( + CorpusIntelligenceSetupStatusType, + corpus_id=graphene.ID(required=True), + description=( + "Which pieces of the default collection-intelligence bundle " + "(reference-web action + description/summary templates) are " + "already installed on the corpus. Null when the corpus is not " + "visible to the requesting user." + ), + ) + + @graphql_ratelimit_dynamic(get_rate=get_user_tier_rate("READ_LIGHT")) + def resolve_corpus_intelligence_setup_status(self, info, corpus_id) -> Any: + """Visibility-scoped via ``CorpusIntelligenceSetupService.status``.""" + from opencontractserver.corpuses.services import ( + CorpusIntelligenceSetupService, + ) + + try: + corpus_pk = int(from_global_id(corpus_id)[1]) + except Exception: + return None + result = CorpusIntelligenceSetupService.status( + info.context.user, corpus_pk, request=info.context + ) + return result.value if result.ok else None + corpus_stats = graphene.Field(CorpusStatsType, corpus_id=graphene.ID(required=True)) @graphql_ratelimit_dynamic(get_rate=get_user_tier_rate("READ_MEDIUM")) diff --git a/config/graphql/corpus_types.py b/config/graphql/corpus_types.py index b578e8f82..3e6100207 100644 --- a/config/graphql/corpus_types.py +++ b/config/graphql/corpus_types.py @@ -956,3 +956,64 @@ def resolve_created(self, info) -> Any: """Document creation timestamp — historical revisions used the same field name.""" return self.created + + +class IntelligenceTemplateOutcomeType(graphene.ObjectType): + """Per-template result from the one-click intelligence setup.""" + + template_name = graphene.String(required=True) + installed_now = graphene.Boolean( + required=True, description="Template was cloned into the corpus by this call." + ) + already_installed = graphene.Boolean( + required=True, description="The corpus already had this template's action." + ) + queued_count = graphene.Int( + required=True, description="Documents queued for an agent run by this call." + ) + skipped_already_run_count = graphene.Int( + required=True, description="Documents skipped because they already ran." + ) + error = graphene.String( + required=True, + description="Per-template failure (empty string when the step succeeded).", + ) + + +class CorpusIntelligenceSetupSummaryType(graphene.ObjectType): + """Result envelope for ``setupCorpusIntelligence``. + + Mirrors ``IntelligenceSetupSummary`` from + ``opencontractserver.corpuses.services.intelligence_setup`` — graphene's + default resolver reads the dataclass attributes directly. + """ + + reference_available = graphene.Boolean( + required=True, + description="The reference-enrichment analyzer is registered on this deployment.", + ) + reference_action_installed_now = graphene.Boolean(required=True) + reference_action_already_installed = graphene.Boolean(required=True) + reference_analysis_started = graphene.Boolean( + required=True, description="An immediate reference-web weave was started." + ) + total_active_documents = graphene.Int(required=True) + templates = graphene.List( + graphene.NonNull(IntelligenceTemplateOutcomeType), required=True + ) + + +class CorpusIntelligenceSetupStatusType(graphene.ObjectType): + """Which intelligence-bundle pieces a corpus already has installed.""" + + reference_action_installed = graphene.Boolean(required=True) + installed_template_names = graphene.List( + graphene.NonNull(graphene.String), required=True + ) + missing_template_names = graphene.List( + graphene.NonNull(graphene.String), required=True + ) + is_fully_set_up = graphene.Boolean( + required=True, + description="Reference action installed and no bundle template missing.", + ) diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index 09c1a42eb..4b4d6d288 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -93,6 +93,7 @@ RemoveDocumentsFromCorpus, RunCorpusAction, SetCorpusVisibility, + SetupCorpusIntelligence, StartCorpusActionBatchRun, StartCorpusFork, ToggleCorpusMemory, @@ -334,6 +335,7 @@ class Mutation(graphene.ObjectType): run_corpus_action = RunCorpusAction.Field() start_corpus_action_batch_run = StartCorpusActionBatchRun.Field() add_template_to_corpus = AddTemplateToCorpus.Field() + setup_corpus_intelligence = SetupCorpusIntelligence.Field() toggle_corpus_memory = ToggleCorpusMemory.Field() # CORPUS CATEGORY MUTATIONS (superuser-only) ############################### diff --git a/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligencePanel.tsx b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligencePanel.tsx index ab9ced474..81a478174 100644 --- a/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligencePanel.tsx +++ b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligencePanel.tsx @@ -15,6 +15,7 @@ import { GetCorpusIntelligenceAggregatesInputType, GetCorpusIntelligenceAggregatesOutputType, } from "../../../../graphql/queries"; +import { IntelligenceSetupBanner } from "./IntelligenceSetupBanner"; /** * IntelligencePanel — the insight-framed "at a glance" panel of the Corpus @@ -244,6 +245,10 @@ export const IntelligencePanel: React.FC = ({ return ( + {/* One-click bundle setup — silent once the corpus is fully set up. + Mounted here so both the overview and the insight-panel CAML embed + surface it. */} + {statsInitialLoading ? ( <> diff --git a/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx new file mode 100644 index 000000000..78f67ce72 --- /dev/null +++ b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useState } from "react"; +import { useMutation, useQuery } from "@apollo/client"; +import styled, { keyframes } from "styled-components"; +import { Loader2, Sparkles } from "lucide-react"; +import { toast } from "react-toastify"; + +import { OS_LEGAL_COLORS } from "../../../../assets/configurations/osLegalStyles"; +import { + GET_CORPUS_INTELLIGENCE_SETUP_STATUS, + GetCorpusIntelligenceSetupStatusInputType, + GetCorpusIntelligenceSetupStatusOutputType, +} from "../../../../graphql/queries"; +import { + SETUP_CORPUS_INTELLIGENCE, + SetupCorpusIntelligenceInputs, + SetupCorpusIntelligenceOutputs, +} from "../../../../graphql/mutations"; + +/** + * IntelligenceSetupBanner — the one-click "set up collection intelligence" + * entry point. + * + * Renders a slim call-to-action when the corpus is missing pieces of the + * default intelligence bundle (reference-web action, document descriptions, + * document summaries) and nothing at all once the bundle is installed — + * a fully set-up corpus shouldn't advertise setup. Mounted inside + * ``IntelligencePanel`` so every surface that shows the at-a-glance panel + * (intelligence overview and the ``insight-panel`` CAML embed) gets it. + * + * The mutation is idempotent server-side: it installs whatever is missing, + * starts the first reference weave, and batch-runs the description/summary + * agents over every document already in the corpus. + */ + +interface IntelligenceSetupBannerProps { + corpusId: string; + testId?: string; +} + +const spin = keyframes` + to { transform: rotate(360deg); } +`; + +const Banner = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.85rem 1.25rem; + background: linear-gradient( + 100deg, + rgba(37, 99, 235, 0.06), + rgba(201, 164, 92, 0.08) + ); + border: 1px solid ${OS_LEGAL_COLORS.border}; + border-radius: 14px; +`; + +const BannerText = styled.div` + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.8125rem; + color: ${OS_LEGAL_COLORS.textSecondary}; + + svg { + flex-shrink: 0; + width: 16px; + height: 16px; + color: ${OS_LEGAL_COLORS.primaryBlue}; + } + + strong { + color: ${OS_LEGAL_COLORS.textPrimary}; + font-weight: 600; + } +`; + +const SetupButton = styled.button` + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.5rem 1rem; + border: none; + border-radius: 10px; + background: ${OS_LEGAL_COLORS.primaryBlue}; + color: white; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: ${OS_LEGAL_COLORS.primaryBlueHover}; + } + + &:disabled { + opacity: 0.7; + cursor: default; + } + + svg { + width: 14px; + height: 14px; + } + + .spinning { + animation: ${spin} 1.1s linear infinite; + } +`; + +export const IntelligenceSetupBanner: React.FC< + IntelligenceSetupBannerProps +> = ({ corpusId, testId = "intelligence-setup-banner" }) => { + const [submitting, setSubmitting] = useState(false); + + const { data, refetch } = useQuery< + GetCorpusIntelligenceSetupStatusOutputType, + GetCorpusIntelligenceSetupStatusInputType + >(GET_CORPUS_INTELLIGENCE_SETUP_STATUS, { variables: { corpusId } }); + + const [setupIntelligence] = useMutation< + SetupCorpusIntelligenceOutputs, + SetupCorpusIntelligenceInputs + >(SETUP_CORPUS_INTELLIGENCE); + + const handleSetup = useCallback(async () => { + setSubmitting(true); + try { + const { data: result } = await setupIntelligence({ + variables: { corpusId }, + }); + const payload = result?.setupCorpusIntelligence; + if (!payload?.ok) { + toast.error( + payload?.message || "Couldn't set up collection intelligence." + ); + return; + } + const queued = (payload.summary?.templates ?? []).reduce( + (sum, t) => sum + t.queuedCount, + 0 + ); + toast.success( + queued > 0 + ? `Setting up — ${queued} document enrichment ${ + queued === 1 ? "run" : "runs" + } queued${ + payload.summary?.referenceAnalysisStarted + ? ", reference web weaving" + : "" + }.` + : "Collection intelligence is set up." + ); + // Status flips to fully-set-up as soon as the actions exist, which + // hides the banner — the per-surface panels (summary coverage, + // governance graph) show the enrichment landing as it completes. + await refetch(); + } catch { + toast.error("Couldn't set up collection intelligence."); + } finally { + setSubmitting(false); + } + }, [corpusId, refetch, setupIntelligence]); + + const status = data?.corpusIntelligenceSetupStatus; + // Silent while loading, on error, and once fully set up — the banner only + // exists to offer setup, never to report state. + if (!status || status.isFullySetUp) return null; + + return ( + + + + + Set up collection intelligence — map the reference + web, then describe and summarize every document automatically. + + + + {submitting ? : } + Set up + + + ); +}; diff --git a/frontend/src/components/corpuses/CorpusModal.tsx b/frontend/src/components/corpuses/CorpusModal.tsx index 049039dcb..669822447 100644 --- a/frontend/src/components/corpuses/CorpusModal.tsx +++ b/frontend/src/components/corpuses/CorpusModal.tsx @@ -7,6 +7,7 @@ import { ModalBody, ModalFooter, Button, + Checkbox, Input, Textarea, Spinner, @@ -41,6 +42,9 @@ export type CorpusModalMode = "CREATE" | "EDIT" | "VIEW"; export interface CorpusFormData { id?: string; + /** Create mode only: run the one-click collection-intelligence setup + * (reference web + document descriptions/summaries) after creation. */ + setupIntelligence?: boolean; title?: string; slug?: string; description?: string; @@ -362,6 +366,9 @@ export const CorpusModal: React.FC = ({ // Form state const [title, setTitle] = useState(""); + // Create-mode opt-in for the post-create intelligence setup (default on — + // the recommended path; the agent runs scale with document count). + const [setupIntelligence, setSetupIntelligence] = useState(true); const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [icon, setIcon] = useState(null); @@ -619,6 +626,7 @@ export const CorpusModal: React.FC = ({ formData.categories = categories; formData.license = license; formData.licenseLink = licenseLink; + formData.setupIntelligence = setupIntelligence; } onSubmit(formData); @@ -637,6 +645,7 @@ export const CorpusModal: React.FC = ({ categories, license, licenseLink, + setupIntelligence, ]); // Get header text based on mode @@ -840,6 +849,21 @@ export const CorpusModal: React.FC = ({ upward /> + + {isCreate && ( + + ) => + setSetupIntelligence(e.target.checked) + } + disabled={loading} + label="Set up collection intelligence — map the reference web and auto-describe/summarize documents as they arrive" + /> + + )} diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index e0847eb96..b392028a2 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -249,6 +249,9 @@ export interface CreateCorpusOutputs { createCorpus: { ok?: boolean; message?: string; + /** Global id of the created corpus — lets follow-up mutations (e.g. + * setupCorpusIntelligence) chain off the create. */ + objId?: string | null; }; } @@ -277,6 +280,63 @@ export const CREATE_CORPUS = gql` ) { ok message + objId + } + } +`; + +// ---------------- Collection-intelligence setup ---------------- +// One-click composite: installs the reference-enrichment add_document action +// and the description/summary agent templates, starts the first reference +// weave, and batch-runs the agents over every document already present. +// Idempotent server-side — safe to call repeatedly. + +export interface IntelligenceTemplateOutcome { + templateName: string; + installedNow: boolean; + alreadyInstalled: boolean; + queuedCount: number; + skippedAlreadyRunCount: number; + error: string; +} + +export interface SetupCorpusIntelligenceInputs { + corpusId: string; +} + +export interface SetupCorpusIntelligenceOutputs { + setupCorpusIntelligence: { + ok: boolean; + message?: string | null; + summary?: { + referenceAvailable: boolean; + referenceActionInstalledNow: boolean; + referenceAnalysisStarted: boolean; + totalActiveDocuments: number; + templates: IntelligenceTemplateOutcome[]; + } | null; + }; +} + +export const SETUP_CORPUS_INTELLIGENCE = gql` + mutation setupCorpusIntelligence($corpusId: ID!) { + setupCorpusIntelligence(corpusId: $corpusId) { + ok + message + summary { + referenceAvailable + referenceActionInstalledNow + referenceAnalysisStarted + totalActiveDocuments + templates { + templateName + installedNow + alreadyInstalled + queuedCount + skippedAlreadyRunCount + error + } + } } } `; diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index a8821a6d3..4ad566e9b 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -712,6 +712,35 @@ export interface GetCorpusStatsOutputType { corpusStats: CorpusStats; } +// Which pieces of the default collection-intelligence bundle (reference-web +// action + description/summary agent templates) a corpus already has — +// drives the one-click setup banner's visibility. +export interface CorpusIntelligenceSetupStatus { + referenceActionInstalled: boolean; + installedTemplateNames: string[]; + missingTemplateNames: string[]; + isFullySetUp: boolean; +} + +export interface GetCorpusIntelligenceSetupStatusInputType { + corpusId: string; +} + +export interface GetCorpusIntelligenceSetupStatusOutputType { + corpusIntelligenceSetupStatus: CorpusIntelligenceSetupStatus | null; +} + +export const GET_CORPUS_INTELLIGENCE_SETUP_STATUS = gql` + query corpusIntelligenceSetupStatus($corpusId: ID!) { + corpusIntelligenceSetupStatus(corpusId: $corpusId) { + referenceActionInstalled + installedTemplateNames + missingTemplateNames + isFullySetUp + } + } +`; + export const GET_CORPUS_STATS = gql` query corpusStats($corpusId: ID!) { corpusStats(corpusId: $corpusId) { diff --git a/frontend/src/views/Corpuses.tsx b/frontend/src/views/Corpuses.tsx index 6877836aa..b765f9f24 100644 --- a/frontend/src/views/Corpuses.tsx +++ b/frontend/src/views/Corpuses.tsx @@ -73,6 +73,9 @@ import { CREATE_CORPUS, CreateCorpusOutputs, CreateCorpusInputs, + SETUP_CORPUS_INTELLIGENCE, + SetupCorpusIntelligenceInputs, + SetupCorpusIntelligenceOutputs, DELETE_CORPUS, DeleteCorpusOutputs, DeleteCorpusInputs, @@ -834,6 +837,11 @@ export const Corpuses = () => { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Query to delete corpus /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [trySetupIntelligence] = useMutation< + SetupCorpusIntelligenceOutputs, + SetupCorpusIntelligenceInputs + >(SETUP_CORPUS_INTELLIGENCE); + const [tryCreateCorpus, { loading: create_corpus_loading }] = useMutation< CreateCorpusOutputs, CreateCorpusInputs @@ -950,6 +958,20 @@ export const Corpuses = () => { .then((data) => { if (data.data?.createCorpus.ok) { toast.success("SUCCESS. Created corpus."); + // Opt-in one-click intelligence setup: installs the reference-web + // action + description/summary agents and kicks them off. Failure + // here must not read as a failed corpus creation — surface softly. + const newCorpusId = data.data.createCorpus.objId; + if (formData.setupIntelligence && newCorpusId) { + trySetupIntelligence({ + variables: { corpusId: newCorpusId }, + }).catch(() => { + toast.info( + "Corpus created, but intelligence setup couldn't start — " + + "you can run it from the corpus page." + ); + }); + } } else { toast.error(`FAILED on server: ${data.data?.createCorpus.message}`); } diff --git a/frontend/tests/IntelligenceSetupBanner.ct.tsx b/frontend/tests/IntelligenceSetupBanner.ct.tsx new file mode 100644 index 000000000..eca8755f0 --- /dev/null +++ b/frontend/tests/IntelligenceSetupBanner.ct.tsx @@ -0,0 +1,142 @@ +/** + * Component tests for IntelligenceSetupBanner — the one-click "set up + * collection intelligence" CTA mounted inside IntelligencePanel. Hidden when + * the bundle is fully installed; clicking fires the idempotent + * setupCorpusIntelligence mutation and hides the banner once the refetched + * status reports fully-set-up. + * + * NOTE: each JSX-component import is kept in its own ``import`` statement, + * separate from all other imports, per the Playwright CT split-import rule. + */ +import React from "react"; +import { test, expect } from "./utils/coverage"; +import { MockedProvider } from "@apollo/client/testing"; +import { ToastContainer } from "react-toastify"; +import { IntelligenceSetupBanner } from "../src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner"; +import { docScreenshot } from "./utils/docScreenshot"; +import { GET_CORPUS_INTELLIGENCE_SETUP_STATUS } from "../src/graphql/queries"; +import { SETUP_CORPUS_INTELLIGENCE } from "../src/graphql/mutations"; + +const CORPUS_ID = "Q29ycHVzVHlwZTox"; + +const statusMock = (isSet: boolean) => ({ + request: { + query: GET_CORPUS_INTELLIGENCE_SETUP_STATUS, + variables: { corpusId: CORPUS_ID }, + }, + result: { + data: { + corpusIntelligenceSetupStatus: { + referenceActionInstalled: isSet, + installedTemplateNames: isSet + ? ["Document Description Updater", "Document Summary Generator"] + : [], + missingTemplateNames: isSet + ? [] + : ["Document Description Updater", "Document Summary Generator"], + isFullySetUp: isSet, + }, + }, + }, +}); + +const setupMock = { + request: { + query: SETUP_CORPUS_INTELLIGENCE, + variables: { corpusId: CORPUS_ID }, + }, + result: { + data: { + setupCorpusIntelligence: { + ok: true, + message: "Collection intelligence setup started.", + summary: { + referenceAvailable: true, + referenceActionInstalledNow: true, + referenceAnalysisStarted: true, + totalActiveDocuments: 12, + templates: [ + { + templateName: "Document Description Updater", + installedNow: true, + alreadyInstalled: false, + queuedCount: 12, + skippedAlreadyRunCount: 0, + error: "", + }, + { + templateName: "Document Summary Generator", + installedNow: true, + alreadyInstalled: false, + queuedCount: 12, + skippedAlreadyRunCount: 0, + error: "", + }, + ], + }, + }, + }, + }, +}; + +test.describe("IntelligenceSetupBanner", () => { + test("offers setup when the bundle is missing, then hides after running it", async ({ + mount, + page, + }) => { + const component = await mount( + + <> + + + + + ); + + const banner = page.locator('[data-testid="intelligence-setup-banner"]'); + await expect(banner).toBeVisible({ timeout: 10000 }); + await expect(banner).toContainText("Set up collection intelligence"); + + await docScreenshot(page, "corpus--intelligence-setup-banner--offer"); + + await page + .locator('[data-testid="intelligence-setup-banner-button"]') + .click(); + + // Success toast reports the queued enrichment fan-out. + await expect( + page.getByText(/24 document enrichment runs queued/i) + ).toBeVisible({ timeout: 10000 }); + + // Refetched status is fully-set-up → the banner disappears. + await expect(banner).toHaveCount(0, { timeout: 10000 }); + + await component.unmount(); + }); + + test("renders nothing when the corpus is already fully set up", async ({ + mount, + page, + }) => { + const component = await mount( + + + + ); + + await page.waitForTimeout(1000); + await expect( + page.locator('[data-testid="intelligence-setup-banner"]') + ).toHaveCount(0); + + await component.unmount(); + }); +}); diff --git a/opencontractserver/constants/corpus_actions.py b/opencontractserver/constants/corpus_actions.py index 3254278b2..af736ee67 100644 --- a/opencontractserver/constants/corpus_actions.py +++ b/opencontractserver/constants/corpus_actions.py @@ -10,6 +10,25 @@ ``test_corpus_action_model.py``. """ +# --------------------------------------------------------------------------- +# One-click "collection intelligence" setup bundle +# --------------------------------------------------------------------------- +# Seeded CorpusActionTemplate names installed (and batch-run over existing +# documents) by ``CorpusIntelligenceSetupService``. Names must match +# ``opencontractserver/corpuses/template_seeds.py``; alignment is pinned by +# ``test_intelligence_setup.py``. Deliberately the lean default — heavier +# templates (key terms, notes) stay opt-in via the Action Library. + +INTELLIGENCE_SETUP_TEMPLATE_NAMES: list[str] = [ + "Document Description Updater", + "Document Summary Generator", +] + +# Display name for the auto-installed reference-enrichment analyzer action. +# The governance graph's "Map the reference web" CTA creates the same action +# client-side with this name, so the two entry points converge on one row. +REFERENCE_ENRICHMENT_ACTION_NAME = "Reference enrichment (auto)" + # --------------------------------------------------------------------------- # Default tool sets by trigger type # --------------------------------------------------------------------------- diff --git a/opencontractserver/corpuses/services/__init__.py b/opencontractserver/corpuses/services/__init__.py index 8132f55df..f778b52ea 100644 --- a/opencontractserver/corpuses/services/__init__.py +++ b/opencontractserver/corpuses/services/__init__.py @@ -43,12 +43,20 @@ FolderDocumentService, ) from opencontractserver.corpuses.services.folders import FolderCRUDService +from opencontractserver.corpuses.services.intelligence_setup import ( + CorpusIntelligenceSetupService, + IntelligenceSetupStatus, + IntelligenceSetupSummary, +) from opencontractserver.corpuses.services.lifecycle import DocumentLifecycleService from opencontractserver.corpuses.services.paths import CorpusPathService from opencontractserver.corpuses.services.votes import CorpusVoteService __all__ = [ "BatchRunSummary", + "CorpusIntelligenceSetupService", + "IntelligenceSetupStatus", + "IntelligenceSetupSummary", "CorpusActionService", "CorpusCategoryService", "FolderCRUDService", diff --git a/opencontractserver/corpuses/services/intelligence_setup.py b/opencontractserver/corpuses/services/intelligence_setup.py new file mode 100644 index 000000000..57405a8a1 --- /dev/null +++ b/opencontractserver/corpuses/services/intelligence_setup.py @@ -0,0 +1,342 @@ +"""One-click "collection intelligence" setup. + +Composes the existing enrichment machinery into a single idempotent call — +the orchestration layer the pieces were missing: + +1. **Deterministic**: installs the reference-enrichment analyzer as an + ``add_document`` CorpusAction (the same row the governance graph's + "Map the reference web" CTA creates) and starts an immediate corpus + analysis so the reference web weaves now, not just on the next upload. +2. **LLM-backed**: clones the seeded action templates named in + ``INTELLIGENCE_SETUP_TEMPLATE_NAMES`` (document descriptions + summaries) + into the corpus and batch-runs each over every document already present. + +Every step skips work that already exists (action rows are deduped, batch +runs skip already-executed documents, an in-flight reference analysis is not +duplicated), so the call is safe to repeat — re-running converges instead of +fanning out duplicate agent runs. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +from opencontractserver.constants.corpus_actions import ( + INTELLIGENCE_SETUP_TEMPLATE_NAMES, + REFERENCE_ENRICHMENT_ACTION_NAME, +) +from opencontractserver.shared.services.base import BaseService +from opencontractserver.shared.services.conventions import ServiceResult +from opencontractserver.types.enums import PermissionTypes + +logger = logging.getLogger(__name__) + + +@dataclass +class TemplateSetupOutcome: + """Per-template result of the LLM half of the bundle.""" + + template_name: str + installed_now: bool + already_installed: bool + queued_count: int + skipped_already_run_count: int + error: str = "" + + +@dataclass +class IntelligenceSetupSummary: + """Result envelope for ``CorpusIntelligenceSetupService.setup``.""" + + reference_action_installed_now: bool + reference_action_already_installed: bool + reference_analysis_started: bool + reference_available: bool + templates: list[TemplateSetupOutcome] = field(default_factory=list) + total_active_documents: int = 0 + + +@dataclass +class IntelligenceSetupStatus: + """Which bundle pieces a corpus already has (drives the setup CTA).""" + + reference_action_installed: bool + installed_template_names: list[str] + missing_template_names: list[str] + + @property + def is_fully_set_up(self) -> bool: + return self.reference_action_installed and not self.missing_template_names + + +class CorpusIntelligenceSetupService(BaseService): + """Install + kick off the default corpus-intelligence bundle.""" + + _NOT_FOUND_MESSAGE = "Corpus not found or you don't have permission." + + # ------------------------------------------------------------------ + # Status (read-only; powers the CTA's visibility) + # ------------------------------------------------------------------ + @classmethod + def status( + cls, + user: Any, + corpus_pk: int, + *, + request: Any = None, + ) -> ServiceResult[IntelligenceSetupStatus]: + """Report which bundle pieces are already installed on the corpus.""" + from opencontractserver.corpuses.models import Corpus, CorpusAction + + corpus = cls.get_or_none(Corpus, corpus_pk, user) + if corpus is None: + return ServiceResult.failure(cls._NOT_FOUND_MESSAGE) + + reference_installed = cls._reference_action_qs(corpus).exists() + installed = list( + CorpusAction.objects.filter( + corpus=corpus, + source_template__name__in=INTELLIGENCE_SETUP_TEMPLATE_NAMES, + ).values_list("source_template__name", flat=True) + ) + missing = [ + name for name in INTELLIGENCE_SETUP_TEMPLATE_NAMES if name not in installed + ] + return ServiceResult.success( + IntelligenceSetupStatus( + reference_action_installed=reference_installed, + installed_template_names=installed, + missing_template_names=missing, + ) + ) + + # ------------------------------------------------------------------ + # Setup (mutating) + # ------------------------------------------------------------------ + @classmethod + def setup( + cls, + user: Any, + corpus_pk: int, + *, + request: Any = None, + ) -> ServiceResult[IntelligenceSetupSummary]: + """Install the bundle and kick off enrichment over existing documents.""" + from opencontractserver.corpuses.models import Corpus + + corpus = cls.get_or_none(Corpus, corpus_pk, user) + if corpus is None: + return ServiceResult.failure(cls._NOT_FOUND_MESSAGE) + error = cls.require_permission( + corpus, + user, + PermissionTypes.UPDATE, + request=request, + error_message=cls._NOT_FOUND_MESSAGE, + ) + if error: + return ServiceResult.failure(error) + + summary = IntelligenceSetupSummary( + reference_action_installed_now=False, + reference_action_already_installed=False, + reference_analysis_started=False, + reference_available=False, + total_active_documents=corpus._get_active_documents().count(), + ) + + cls._setup_reference_enrichment(user, corpus, summary, request=request) + cls._setup_templates(user, corpus, summary, request=request) + + cls.log_action( + "Intelligence setup for", + corpus, + user, + reference_started=summary.reference_analysis_started, + templates_installed=[ + t.template_name for t in summary.templates if t.installed_now + ], + queued=sum(t.queued_count for t in summary.templates), + ) + return ServiceResult.success(summary) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + @classmethod + def _reference_action_qs(cls, corpus: Any): + """The corpus's add_document reference-enrichment action rows.""" + from opencontractserver.corpuses.models import ( + CorpusAction, + CorpusActionTrigger, + ) + from opencontractserver.enrichment import constants as enrichment_constants + + return CorpusAction.objects.filter( + corpus=corpus, + trigger=CorpusActionTrigger.ADD_DOCUMENT.value, + analyzer__task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK, + ) + + @classmethod + def _setup_reference_enrichment( + cls, + user: Any, + corpus: Any, + summary: IntelligenceSetupSummary, + *, + request: Any = None, + ) -> None: + """Install the reference-web action and start the first weave.""" + from opencontractserver.analyzer.models import Analysis, Analyzer + from opencontractserver.analyzer.services.analysis_lifecycle_service import ( + AnalysisLifecycleService, + ) + from opencontractserver.corpuses.models import ( + CorpusAction, + CorpusActionTrigger, + ) + from opencontractserver.enrichment import constants as enrichment_constants + from opencontractserver.types.enums import JobStatus + from opencontractserver.utils.permissioning import ( + set_permissions_for_obj_to_user, + ) + + analyzer = Analyzer.objects.filter( + task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK + ).first() + if analyzer is None: + # Deployment without the enrichment analyzer registered — the + # LLM half of the bundle still proceeds. + logger.info( + "Intelligence setup: reference-enrichment analyzer not " + "registered; skipping the deterministic half." + ) + return + summary.reference_available = True + + if cls._reference_action_qs(corpus).exists(): + summary.reference_action_already_installed = True + else: + action = CorpusAction.objects.create( + name=REFERENCE_ENRICHMENT_ACTION_NAME, + corpus=corpus, + analyzer=analyzer, + trigger=CorpusActionTrigger.ADD_DOCUMENT.value, + creator=user, + ) + set_permissions_for_obj_to_user( + user, action, [PermissionTypes.CRUD], request=request + ) + summary.reference_action_installed_now = True + + # First weave now (not just on the next upload) — unless one is + # already in flight, in which case starting another would only + # duplicate work the running analysis will do anyway. + in_flight = Analysis.objects.filter( + analyzer=analyzer, + analyzed_corpus=corpus, + status__in=[JobStatus.QUEUED.value, JobStatus.RUNNING.value], + ).exists() + if in_flight: + return + result = AnalysisLifecycleService.start_document_analysis( + user, + analyzer_pk=analyzer.pk, + corpus_pk=corpus.pk, + request=request, + ) + summary.reference_analysis_started = bool(result.ok) + if not result.ok: + logger.warning( + "Intelligence setup: reference analysis failed to start for " + "corpus %s: %s", + corpus.pk, + result.error, + ) + + @classmethod + def _setup_templates( + cls, + user: Any, + corpus: Any, + summary: IntelligenceSetupSummary, + *, + request: Any = None, + ) -> None: + """Clone the bundle templates and batch-run each over existing docs.""" + from django.db import IntegrityError, transaction + + from opencontractserver.corpuses.models import ( + CorpusAction, + CorpusActionTemplate, + ) + from opencontractserver.corpuses.services.corpus_actions import ( + CorpusActionService, + ) + from opencontractserver.utils.permissioning import ( + set_permissions_for_obj_to_user, + ) + + for name in INTELLIGENCE_SETUP_TEMPLATE_NAMES: + outcome = TemplateSetupOutcome( + template_name=name, + installed_now=False, + already_installed=False, + queued_count=0, + skipped_already_run_count=0, + ) + summary.templates.append(outcome) + + template = CorpusActionTemplate.objects.filter( + name=name, is_active=True + ).first() + if template is None: + outcome.error = "Template not found or inactive." + logger.warning( + "Intelligence setup: template %r missing on corpus %s", + name, + corpus.pk, + ) + continue + + action = CorpusAction.objects.filter( + corpus=corpus, source_template=template + ).first() + if action is not None: + outcome.already_installed = True + else: + try: + # Savepoint so a duplicate-insert race doesn't poison the + # outer transaction (mirrors AddTemplateToCorpus). + with transaction.atomic(): + action = template.clone_to_corpus(corpus, creator=user) + except IntegrityError: + action = CorpusAction.objects.filter( + corpus=corpus, source_template=template + ).first() + outcome.already_installed = action is not None + if action is None: + outcome.error = "Failed to install template." + continue + if outcome.already_installed is False: + set_permissions_for_obj_to_user( + user, action, [PermissionTypes.CRUD], request=request + ) + outcome.installed_now = True + + batch = CorpusActionService.batch_run_on_corpus( + user, action.pk, request=request + ) + if batch.ok and batch.value is not None: + outcome.queued_count = batch.value.queued_count + outcome.skipped_already_run_count = ( + batch.value.skipped_already_run_count + ) + else: + # Surface (e.g. the BATCH_RUN_MAX_DOCS cap) without failing + # the whole setup — the action is installed and will run on + # future uploads regardless. + outcome.error = batch.error or "Batch run failed." diff --git a/opencontractserver/tests/test_intelligence_setup.py b/opencontractserver/tests/test_intelligence_setup.py new file mode 100644 index 000000000..f2a03b9d4 --- /dev/null +++ b/opencontractserver/tests/test_intelligence_setup.py @@ -0,0 +1,286 @@ +"""Tests for the one-click collection-intelligence setup. + +Covers ``CorpusIntelligenceSetupService`` (install + idempotence + permission +gating + status) and the ``setupCorpusIntelligence`` / +``corpusIntelligenceSetupStatus`` GraphQL surface. + +The LLM batch runs are queued via ``transaction.on_commit`` — under +``TestCase`` those callbacks never fire, so no agent run is attempted; the +queued ``CorpusActionExecution`` rows are the observable contract. The +deterministic half's analysis start is patched (it has its own test +coverage in the analyzer suite). +""" + +from typing import Any +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from graphql_relay import to_global_id + +from opencontractserver.constants.corpus_actions import ( + INTELLIGENCE_SETUP_TEMPLATE_NAMES, + REFERENCE_ENRICHMENT_ACTION_NAME, +) +from opencontractserver.corpuses.models import ( + Corpus, + CorpusAction, + CorpusActionExecution, + CorpusActionTemplate, +) +from opencontractserver.corpuses.services import CorpusIntelligenceSetupService +from opencontractserver.documents.models import Document +from opencontractserver.enrichment import constants as enrichment_constants +from opencontractserver.shared.services.conventions import ServiceResult + +User = get_user_model() + +_START_ANALYSIS = ( + "opencontractserver.analyzer.services.analysis_lifecycle_service." + "AnalysisLifecycleService.start_document_analysis" +) + + +def _ok_analysis(*args, **kwargs): + return ServiceResult.success(object()) + + +def _seed_bundle_dependencies(creator_id: int) -> None: + """Seed the templates + enrichment analyzer the bundle composes. + + Mirrors what the data migrations (`agents/0010`, analyzer auto-sync) + provide in a live deployment — the test DB starts empty, so each test + class seeds explicitly (same pattern as ``test_corpus_action_template``). + """ + from django.apps import apps + + from opencontractserver.corpuses.template_seeds import ( + create_default_action_templates, + ) + from opencontractserver.enrichment.services import EnrichmentService + + # The seeder skips silently unless a superuser exists to own the + # AgentConfigurations (mirrors test_corpus_action_template). + User.objects.get_or_create( + username="migration_admin", + defaults={"is_superuser": True, "is_staff": True, "password": "x"}, + ) + create_default_action_templates(apps, None) + EnrichmentService.get_or_create_analyzer(creator_id) + + +class IntelligenceSetupServiceTestCase(TestCase): + user: Any + stranger: Any + corpus: Corpus + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username="owner", password="x") + cls.stranger = User.objects.create_user(username="stranger", password="x") + _seed_bundle_dependencies(cls.user.id) + cls.corpus = Corpus.objects.create(title="Setup Corpus", creator=cls.user) + for i in range(3): + doc = Document.objects.create( + title=f"Doc {i}", creator=cls.user, description="" + ) + doc._skip_signals = True + cls.corpus.add_document(document=doc, user=cls.user) + + def test_bundle_template_names_are_seeded(self): + """The constants must keep matching the seeded template names.""" + for name in INTELLIGENCE_SETUP_TEMPLATE_NAMES: + self.assertTrue( + CorpusActionTemplate.objects.filter(name=name, is_active=True).exists(), + f"Bundle template {name!r} is not seeded/active", + ) + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_installs_bundle(self, mock_start): + result = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(result.ok, result.error) + summary = result.value + assert summary is not None + + # Deterministic half: action row + immediate weave. + ref_actions = CorpusAction.objects.filter( + corpus=self.corpus, + analyzer__task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK, + ) + self.assertEqual(ref_actions.count(), 1) + self.assertEqual(ref_actions.get().name, REFERENCE_ENRICHMENT_ACTION_NAME) + self.assertTrue(summary.reference_available) + self.assertTrue(summary.reference_action_installed_now) + self.assertTrue(summary.reference_analysis_started) + mock_start.assert_called_once() + + # LLM half: one cloned action per template, every doc queued. + self.assertEqual(len(summary.templates), len(INTELLIGENCE_SETUP_TEMPLATE_NAMES)) + for outcome in summary.templates: + self.assertTrue(outcome.installed_now, outcome.template_name) + self.assertEqual(outcome.queued_count, 3, outcome.template_name) + self.assertEqual(outcome.error, "") + self.assertEqual( + CorpusAction.objects.filter( + corpus=self.corpus, + source_template__name__in=INTELLIGENCE_SETUP_TEMPLATE_NAMES, + ).count(), + len(INTELLIGENCE_SETUP_TEMPLATE_NAMES), + ) + self.assertEqual( + CorpusActionExecution.objects.filter( + corpus_action__corpus=self.corpus + ).count(), + 3 * len(INTELLIGENCE_SETUP_TEMPLATE_NAMES), + ) + self.assertEqual(summary.total_active_documents, 3) + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_is_idempotent(self, mock_start): + first = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(first.ok, first.error) + second = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(second.ok, second.error) + summary = second.value + assert summary is not None + + self.assertFalse(summary.reference_action_installed_now) + self.assertTrue(summary.reference_action_already_installed) + for outcome in summary.templates: + self.assertFalse(outcome.installed_now, outcome.template_name) + self.assertTrue(outcome.already_installed, outcome.template_name) + self.assertEqual(outcome.queued_count, 0, outcome.template_name) + self.assertEqual(outcome.skipped_already_run_count, 3) + # No duplicate action rows, no duplicate executions. + self.assertEqual( + CorpusAction.objects.filter(corpus=self.corpus).count(), + 1 + len(INTELLIGENCE_SETUP_TEMPLATE_NAMES), + ) + self.assertEqual( + CorpusActionExecution.objects.filter( + corpus_action__corpus=self.corpus + ).count(), + 3 * len(INTELLIGENCE_SETUP_TEMPLATE_NAMES), + ) + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_requires_update_permission(self, mock_start): + result = CorpusIntelligenceSetupService.setup(self.stranger, self.corpus.pk) + self.assertFalse(result.ok) + self.assertEqual( + result.error, CorpusIntelligenceSetupService._NOT_FOUND_MESSAGE + ) + self.assertFalse(CorpusAction.objects.filter(corpus=self.corpus).exists()) + mock_start.assert_not_called() + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_status_before_and_after(self, mock_start): + before = CorpusIntelligenceSetupService.status(self.user, self.corpus.pk) + self.assertTrue(before.ok) + before_status = before.value + assert before_status is not None + self.assertFalse(before_status.reference_action_installed) + self.assertEqual( + before_status.missing_template_names, INTELLIGENCE_SETUP_TEMPLATE_NAMES + ) + self.assertFalse(before_status.is_fully_set_up) + + CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + + after = CorpusIntelligenceSetupService.status(self.user, self.corpus.pk) + self.assertTrue(after.ok) + after_status = after.value + assert after_status is not None + self.assertTrue(after_status.reference_action_installed) + self.assertEqual(after_status.missing_template_names, []) + self.assertTrue(after_status.is_fully_set_up) + + def test_status_invisible_corpus(self): + result = CorpusIntelligenceSetupService.status(self.stranger, self.corpus.pk) + self.assertFalse(result.ok) + + +class IntelligenceSetupGraphQLTestCase(TestCase): + """Schema-level smoke tests via graphql_sync execution.""" + + user: Any + corpus: Corpus + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username="gql-owner", password="x") + _seed_bundle_dependencies(cls.user.id) + cls.corpus = Corpus.objects.create(title="GQL Setup Corpus", creator=cls.user) + doc = Document.objects.create(title="Doc", creator=cls.user, description="") + doc._skip_signals = True + cls.corpus.add_document(document=doc, user=cls.user) + + def _execute(self, query: str, variables: dict, user) -> dict: + from django.test import RequestFactory + + from config.graphql.schema import schema + + request = RequestFactory().post("/graphql/") + request.user = user + result = schema.execute(query, variable_values=variables, context_value=request) + self.assertIsNone(result.errors, result.errors) + return result.data + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_mutation_and_status_query(self, mock_start): + gid = to_global_id("CorpusType", self.corpus.pk) + + data = self._execute( + """ + mutation Setup($id: ID!) { + setupCorpusIntelligence(corpusId: $id) { + ok + message + summary { + referenceAvailable + referenceActionInstalledNow + referenceAnalysisStarted + totalActiveDocuments + templates { + templateName + installedNow + queuedCount + error + } + } + } + } + """, + {"id": gid}, + self.user, + ) + payload = data["setupCorpusIntelligence"] + self.assertTrue(payload["ok"], payload["message"]) + self.assertTrue(payload["summary"]["referenceActionInstalledNow"]) + self.assertEqual(payload["summary"]["totalActiveDocuments"], 1) + self.assertEqual( + {t["templateName"] for t in payload["summary"]["templates"]}, + set(INTELLIGENCE_SETUP_TEMPLATE_NAMES), + ) + for t in payload["summary"]["templates"]: + self.assertTrue(t["installedNow"]) + self.assertEqual(t["queuedCount"], 1) + + data = self._execute( + """ + query Status($id: ID!) { + corpusIntelligenceSetupStatus(corpusId: $id) { + referenceActionInstalled + installedTemplateNames + missingTemplateNames + isFullySetUp + } + } + """, + {"id": gid}, + self.user, + ) + status = data["corpusIntelligenceSetupStatus"] + self.assertTrue(status["referenceActionInstalled"]) + self.assertEqual(status["missingTemplateNames"], []) + self.assertTrue(status["isFullySetUp"]) From 9137836fb936d503c7aeba02b6989ab873c74617 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 10 Jun 2026 23:52:33 -0500 Subject: [PATCH 02/13] Address review: service-layer doc count, mutation rate limit, TOCTOU note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - intelligence_setup.py: count active documents through CorpusDocumentService.get_corpus_documents (corpus-as-gate; the setup user holds UPDATE→READ) instead of reaching into the private Corpus._get_active_documents — same set, include_caml=False on both. - corpus_mutations.py: rate-limit SetupCorpusIntelligence with WRITE_HEAVY, matching StartCorpusActionBatchRun (it likewise fans out batch agent runs). - intelligence_setup.py: document the deliberate non-atomic in-flight check / analysis start (duplicate weave is recoverable; the writer is idempotent). createCorpus already emits obj_id via DRFMutation (base.py:197); no change needed. log_action accepts **extra, so the kwargs call is safe. --- config/graphql/corpus_mutations.py | 1 + .../corpuses/services/intelligence_setup.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/config/graphql/corpus_mutations.py b/config/graphql/corpus_mutations.py index 2fb94e39f..75b45a768 100644 --- a/config/graphql/corpus_mutations.py +++ b/config/graphql/corpus_mutations.py @@ -1677,6 +1677,7 @@ class Arguments: summary = graphene.Field(CorpusIntelligenceSetupSummaryType) @login_required + @graphql_ratelimit(rate=RateLimits.WRITE_HEAVY) def mutate(root, info, corpus_id: str) -> "SetupCorpusIntelligence": from opencontractserver.corpuses.services import ( CorpusIntelligenceSetupService, diff --git a/opencontractserver/corpuses/services/intelligence_setup.py b/opencontractserver/corpuses/services/intelligence_setup.py index 57405a8a1..81c0a47b5 100644 --- a/opencontractserver/corpuses/services/intelligence_setup.py +++ b/opencontractserver/corpuses/services/intelligence_setup.py @@ -125,6 +125,9 @@ def setup( ) -> ServiceResult[IntelligenceSetupSummary]: """Install the bundle and kick off enrichment over existing documents.""" from opencontractserver.corpuses.models import Corpus + from opencontractserver.corpuses.services.corpus_documents import ( + CorpusDocumentService, + ) corpus = cls.get_or_none(Corpus, corpus_pk, user) if corpus is None: @@ -144,7 +147,13 @@ def setup( reference_action_already_installed=False, reference_analysis_started=False, reference_available=False, - total_active_documents=corpus._get_active_documents().count(), + # Corpus-as-gate count through the service (the setup user holds + # UPDATE, hence READ) rather than reaching into the private + # Corpus._get_active_documents — same active-document set, + # include_caml=False on both surfaces. + total_active_documents=CorpusDocumentService.get_corpus_documents( + user, corpus, request=request + ).count(), ) cls._setup_reference_enrichment(user, corpus, summary, request=request) @@ -234,7 +243,11 @@ def _setup_reference_enrichment( # First weave now (not just on the next upload) — unless one is # already in flight, in which case starting another would only - # duplicate work the running analysis will do anyway. + # duplicate work the running analysis will do anyway. The check and the + # start below are intentionally non-atomic: a concurrent request could + # slip between them and start a second analysis, but that is recoverable + # (the enrichment writer is idempotent — just wasted work), so a lock is + # not warranted here. in_flight = Analysis.objects.filter( analyzer=analyzer, analyzed_corpus=corpus, From a7bdd80f540fb4ab6fbf5bed54923db3fe506594 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 11 Jun 2026 00:51:10 -0500 Subject: [PATCH 03/13] Address re-review: mirror summary field, warn on capped batch, tuple - mutations.ts: add referenceActionAlreadyInstalled to SetupCorpusIntelligenceOutputs.summary (interface + query selection) to mirror the required backend field. - IntelligenceSetupBanner: when ok=True but nothing queued AND a template carried an error (e.g. BATCH_RUN_MAX_DOCS cap), show toast.warning instead of 'Collection intelligence is set up.' - corpus_actions.py: INTELLIGENCE_SETUP_TEMPLATE_NAMES list -> tuple (module constant is never mutated); test compares against list(...) accordingly. --- .../intelligence/IntelligenceSetupBanner.tsx | 39 ++++++++++++------- frontend/src/graphql/mutations.ts | 2 + .../constants/corpus_actions.py | 4 +- .../tests/test_intelligence_setup.py | 3 +- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx index 78f67ce72..9683375f7 100644 --- a/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx +++ b/frontend/src/components/corpuses/CorpusHome/intelligence/IntelligenceSetupBanner.tsx @@ -138,21 +138,30 @@ export const IntelligenceSetupBanner: React.FC< ); return; } - const queued = (payload.summary?.templates ?? []).reduce( - (sum, t) => sum + t.queuedCount, - 0 - ); - toast.success( - queued > 0 - ? `Setting up — ${queued} document enrichment ${ - queued === 1 ? "run" : "runs" - } queued${ - payload.summary?.referenceAnalysisStarted - ? ", reference web weaving" - : "" - }.` - : "Collection intelligence is set up." - ); + const templates = payload.summary?.templates ?? []; + const queued = templates.reduce((sum, t) => sum + t.queuedCount, 0); + const hadTemplateError = templates.some((t) => t.error); + if (queued > 0) { + toast.success( + `Setting up — ${queued} document enrichment ${ + queued === 1 ? "run" : "runs" + } queued${ + payload.summary?.referenceAnalysisStarted + ? ", reference web weaving" + : "" + }.` + ); + } else if (hadTemplateError) { + // ok=True but nothing queued and a template carried an error — e.g. the + // batch-run hit BATCH_RUN_MAX_DOCS. Don't claim it's fully set up. + toast.warning( + "Collection intelligence installed, but some document runs " + + "couldn't be queued (e.g. a per-run document cap). Re-run or " + + "check the panels." + ); + } else { + toast.success("Collection intelligence is set up."); + } // Status flips to fully-set-up as soon as the actions exist, which // hides the banner — the per-surface panels (summary coverage, // governance graph) show the enrichment landing as it completes. diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index b392028a2..44a016d1e 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -311,6 +311,7 @@ export interface SetupCorpusIntelligenceOutputs { summary?: { referenceAvailable: boolean; referenceActionInstalledNow: boolean; + referenceActionAlreadyInstalled: boolean; referenceAnalysisStarted: boolean; totalActiveDocuments: number; templates: IntelligenceTemplateOutcome[]; @@ -326,6 +327,7 @@ export const SETUP_CORPUS_INTELLIGENCE = gql` summary { referenceAvailable referenceActionInstalledNow + referenceActionAlreadyInstalled referenceAnalysisStarted totalActiveDocuments templates { diff --git a/opencontractserver/constants/corpus_actions.py b/opencontractserver/constants/corpus_actions.py index af736ee67..857dfe2e0 100644 --- a/opencontractserver/constants/corpus_actions.py +++ b/opencontractserver/constants/corpus_actions.py @@ -19,10 +19,10 @@ # ``test_intelligence_setup.py``. Deliberately the lean default — heavier # templates (key terms, notes) stay opt-in via the Action Library. -INTELLIGENCE_SETUP_TEMPLATE_NAMES: list[str] = [ +INTELLIGENCE_SETUP_TEMPLATE_NAMES: tuple[str, ...] = ( "Document Description Updater", "Document Summary Generator", -] +) # Display name for the auto-installed reference-enrichment analyzer action. # The governance graph's "Map the reference web" CTA creates the same action diff --git a/opencontractserver/tests/test_intelligence_setup.py b/opencontractserver/tests/test_intelligence_setup.py index f2a03b9d4..f80b3e61c 100644 --- a/opencontractserver/tests/test_intelligence_setup.py +++ b/opencontractserver/tests/test_intelligence_setup.py @@ -181,7 +181,8 @@ def test_status_before_and_after(self, mock_start): assert before_status is not None self.assertFalse(before_status.reference_action_installed) self.assertEqual( - before_status.missing_template_names, INTELLIGENCE_SETUP_TEMPLATE_NAMES + before_status.missing_template_names, + list(INTELLIGENCE_SETUP_TEMPLATE_NAMES), ) self.assertFalse(before_status.is_fully_set_up) From afb14e75a6c04b588bb96c55df36786b2bb1dcf5 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 11 Jun 2026 01:53:14 -0500 Subject: [PATCH 04/13] Fix backend patch coverage: lazy schema import + bootstrap command tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the codecov backend-patch miss: test_enrichment_backfill.py and test_enrichment_writer.py imported config.graphql.schema at MODULE level. Under --cov instrumentation that builds the graphene schema at collection time and errors (graphene-django CustomField field-resolution), so the whole file is dropped and its coverage — including the bootstrap_authority management command and the enrichment services — never reaches the upload. That is why a well-tested command showed 0% patch. Defer the schema import into the _execute helpers (the pattern already used by test_enrichment_tools.py / test_governance_graph.py), so the build happens at runtime and the files' coverage is always captured. Also close the genuinely-uncovered bootstrap_authority branches: unknown creator, unreadable spec file, and missing 'sections' list (now 100%), plus relink_corpora_for_keys per-corpus failure isolation and the empty-keys short-circuit. --- .../tests/test_enrichment_backfill.py | 79 ++++++++++++++++++- .../tests/test_enrichment_writer.py | 8 +- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/opencontractserver/tests/test_enrichment_backfill.py b/opencontractserver/tests/test_enrichment_backfill.py index a962383a1..a23e3ec72 100644 --- a/opencontractserver/tests/test_enrichment_backfill.py +++ b/opencontractserver/tests/test_enrichment_backfill.py @@ -18,7 +18,6 @@ from django.test import TestCase from graphene.test import Client -from config.graphql.schema import schema from opencontractserver.annotations.models import CorpusReference from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document @@ -159,6 +158,31 @@ def test_relink_ignores_unrelated_keys(self): assert out["corpora_checked"] == 0 assert out["law_references_linked"] == 0 + def test_relink_empty_keys_short_circuits(self): + # Empty / all-falsy key lists return a zeroed summary without a sweep. + svc = EnrichmentService() + assert svc.relink_corpora_for_keys([])["corpora_checked"] == 0 + assert svc.relink_corpora_for_keys([None, ""])["corpora_checked"] == 0 + + def test_relink_isolates_per_corpus_failure(self): + # One broken corpus must not strand the sweep: the failure is counted + # and the loop continues (documented per-corpus isolation). + from unittest.mock import patch + + librarian = User.objects.create_user(username="librarian", password="p") + self._bootstrap_dgcl(librarian, public=True) + + with patch.object( + EnrichmentService, + "link_external_references", + side_effect=RuntimeError("boom"), + ): + out = EnrichmentService().relink_corpora_for_keys(["dgcl:145", "dgcl:203"]) + assert out["corpora_checked"] == 1 + assert out["corpora_failed"] == 1 + assert out["corpora_relinked"] == 0 + assert out["law_references_linked"] == 0 + def test_subsection_refs_match_their_root_key(self): # Filing cites securities-act:4(a)(2); the authority lands the root # section securities-act:4 — the relink must still pick the corpus up. @@ -276,6 +300,52 @@ def test_command_rejects_malformed_sections(self): path, ) + def test_command_rejects_unknown_creator(self): + from django.core.management.base import CommandError + + path = self._spec_file( + {"sections": [{"key": "dgcl:145", "heading": "DGCL § 145", "text": "x"}]} + ) + with self.assertRaises(CommandError): + call_command( + "bootstrap_authority", + "--creator", + "nobody", + "--title", + "X", + "--file", + path, + ) + + def test_command_rejects_unreadable_spec_file(self): + from django.core.management.base import CommandError + + with self.assertRaises(CommandError): + call_command( + "bootstrap_authority", + "--creator", + "owner", + "--title", + "X", + "--file", + "/nonexistent/path/to/spec.json", + ) + + def test_command_rejects_missing_sections_list(self): + from django.core.management.base import CommandError + + path = self._spec_file({"aliases": ["DGCL"]}) # no "sections" key + with self.assertRaises(CommandError): + call_command( + "bootstrap_authority", + "--creator", + "owner", + "--title", + "X", + "--file", + path, + ) + class WantedAuthoritiesGraphQLTests(TestCase): def setUp(self): @@ -284,6 +354,13 @@ def setUp(self): EnrichmentService().apply(corpus_id=self.corpus.id, creator_id=self.user.id) def _execute(self, user, variables=None): + # Lazy import: building the graphene schema at module import time trips + # a graphene-django field-resolution error under coverage instrumentation + # (collection-time), which silently drops this file's coverage. Importing + # inside the method defers the build to runtime. Mirrors the pattern in + # test_enrichment_tools.py / test_governance_graph.py. + from config.graphql.schema import schema + client = Client(schema) return client.execute( self.QUERY, variable_values=variables, context_value=_GQLContext(user) diff --git a/opencontractserver/tests/test_enrichment_writer.py b/opencontractserver/tests/test_enrichment_writer.py index 4679f6c61..61d826e8a 100644 --- a/opencontractserver/tests/test_enrichment_writer.py +++ b/opencontractserver/tests/test_enrichment_writer.py @@ -5,7 +5,6 @@ from django.test import TestCase from graphene.test import Client -from config.graphql.schema import schema from opencontractserver.annotations.models import ( RELATIONSHIP_LABEL, SPAN_LABEL, @@ -393,6 +392,13 @@ def setUp(self): self.user = User.objects.create_user(username="gql-guard", password="p") def _execute(self, corpus_id_value: str): + # Lazy import: building the graphene schema at module import time trips + # a graphene-django field-resolution error under coverage instrumentation + # (collection-time), which silently drops this file's coverage. Importing + # inside the method defers the build to runtime. Mirrors the pattern in + # test_enrichment_tools.py / test_governance_graph.py. + from config.graphql.schema import schema + client = Client(schema) query = """ query CorpusRefs($corpusId: ID!) { From 66871452ad8f347153a43dab7c01a810b7d37a07 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 11 Jun 2026 08:04:12 -0500 Subject: [PATCH 05/13] Intelligence setup: partial-success fix + lift patch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: - _setup_templates now contains non-IntegrityError clone failures per template (broad except + log + continue) instead of letting them abort the loop and return a 500 with earlier templates half-installed — honoring the bundle's graceful partial-success contract. - status(): documented the three-query cost (fine at one-per-page-load; revisit if ever polled or rendered per corpus-list row). - IntelligenceSetupBanner.ct.tsx setupMock now includes referenceActionAlreadyInstalled, matching the real SETUP_CORPUS_INTELLIGENCE selection set. Coverage (codecov/patch/Backend + /Frontend both below target): - Backend: cover intelligence_setup error/edge branches — analyzer not registered, in-flight analysis suppresses a duplicate start, failed analysis start, inactive template, and the new contained clone-failure path. - Frontend: cover the banner's error-toast, soft-warning (nothing queued + template error), clean set-up, and catch (network error) branches. Confirmed (no change needed): createCorpus.objId is exposed server-side via DRFMutation (config/graphql/base.py:197), so the post-create intelligence setup chain in Corpuses.tsx receives a real id. --- ...2-setup-templates-partial-success.fixed.md | 1 + frontend/tests/IntelligenceSetupBanner.ct.tsx | 241 +++++++++++++++--- .../corpuses/services/intelligence_setup.py | 20 +- .../tests/test_intelligence_setup.py | 115 +++++++++ 4 files changed, 342 insertions(+), 35 deletions(-) create mode 100644 changelog.d/1982-setup-templates-partial-success.fixed.md diff --git a/changelog.d/1982-setup-templates-partial-success.fixed.md b/changelog.d/1982-setup-templates-partial-success.fixed.md new file mode 100644 index 000000000..b9978f9f4 --- /dev/null +++ b/changelog.d/1982-setup-templates-partial-success.fixed.md @@ -0,0 +1 @@ +- `CorpusIntelligenceSetupService._setup_templates` (`opencontractserver/corpuses/services/intelligence_setup.py`) now contains non-`IntegrityError` clone failures (e.g. `OperationalError`, `ValueError`) per template instead of letting them propagate out of the loop. Previously such a failure aborted the remaining templates and returned a 500 with earlier templates left half-installed; the bundle's graceful partial-success contract is now honored — the failing template records its error and the sweep continues. diff --git a/frontend/tests/IntelligenceSetupBanner.ct.tsx b/frontend/tests/IntelligenceSetupBanner.ct.tsx index eca8755f0..373f339ed 100644 --- a/frontend/tests/IntelligenceSetupBanner.ct.tsx +++ b/frontend/tests/IntelligenceSetupBanner.ct.tsx @@ -40,44 +40,47 @@ const statusMock = (isSet: boolean) => ({ }, }); -const setupMock = { +const template = (overrides: Record = {}) => ({ + templateName: "Document Description Updater", + installedNow: true, + alreadyInstalled: false, + queuedCount: 12, + skippedAlreadyRunCount: 0, + error: "", + ...overrides, +}); + +// A successful setup payload. `summary` mirrors the full SETUP_CORPUS_INTELLIGENCE +// selection (including referenceActionAlreadyInstalled, which the real server +// returns) so the mock matches the server contract. +const setupResult = (summaryOverrides: Record = {}) => ({ + setupCorpusIntelligence: { + ok: true, + message: "Collection intelligence setup started.", + summary: { + referenceAvailable: true, + referenceActionInstalledNow: true, + referenceActionAlreadyInstalled: false, + referenceAnalysisStarted: true, + totalActiveDocuments: 12, + templates: [ + template(), + template({ templateName: "Document Summary Generator" }), + ], + ...summaryOverrides, + }, + }, +}); + +const setupMockWith = (data: unknown) => ({ request: { query: SETUP_CORPUS_INTELLIGENCE, variables: { corpusId: CORPUS_ID }, }, - result: { - data: { - setupCorpusIntelligence: { - ok: true, - message: "Collection intelligence setup started.", - summary: { - referenceAvailable: true, - referenceActionInstalledNow: true, - referenceAnalysisStarted: true, - totalActiveDocuments: 12, - templates: [ - { - templateName: "Document Description Updater", - installedNow: true, - alreadyInstalled: false, - queuedCount: 12, - skippedAlreadyRunCount: 0, - error: "", - }, - { - templateName: "Document Summary Generator", - installedNow: true, - alreadyInstalled: false, - queuedCount: 12, - skippedAlreadyRunCount: 0, - error: "", - }, - ], - }, - }, - }, - }, -}; + result: { data }, +}); + +const setupMock = setupMockWith(setupResult()); test.describe("IntelligenceSetupBanner", () => { test("offers setup when the bundle is missing, then hides after running it", async ({ @@ -139,4 +142,174 @@ test.describe("IntelligenceSetupBanner", () => { await component.unmount(); }); + + test("nothing-queued with a template error surfaces a soft warning", async ({ + mount, + page, + }) => { + // ok=true but every run was capped/skipped and a template carried an error + // → warning, not a "fully set up" claim. The banner stays (refetched status + // is still not-fully-set-up). + const component = await mount( + + <> + + + + + ); + + await page + .locator('[data-testid="intelligence-setup-banner-button"]') + .click(); + + await expect( + page.getByText(/some document runs couldn't be queued/i) + ).toBeVisible({ timeout: 10000 }); + + await component.unmount(); + }); + + test("nothing-queued and no errors reports a clean set-up", async ({ + mount, + page, + }) => { + const component = await mount( + + <> + + + + + ); + + await page + .locator('[data-testid="intelligence-setup-banner-button"]') + .click(); + + await expect( + page.getByText(/Collection intelligence is set up\./i) + ).toBeVisible({ timeout: 10000 }); + + await component.unmount(); + }); + + test("a failed mutation surfaces an error toast and keeps the banner", async ({ + mount, + page, + }) => { + const component = await mount( + + <> + + + + + ); + + const banner = page.locator('[data-testid="intelligence-setup-banner"]'); + await expect(banner).toBeVisible({ timeout: 10000 }); + await page + .locator('[data-testid="intelligence-setup-banner-button"]') + .click(); + + await expect( + page.getByText(/don't have permission to set up this corpus/i) + ).toBeVisible({ timeout: 10000 }); + // !ok returns before refetch → the banner is still offered. + await expect(banner).toBeVisible(); + + await component.unmount(); + }); + + test("a network/mutation error surfaces the generic error toast", async ({ + mount, + page, + }) => { + const component = await mount( + + <> + + + + + ); + + await page + .locator('[data-testid="intelligence-setup-banner-button"]') + .click(); + + await expect( + page.getByText(/Couldn't set up collection intelligence\./i) + ).toBeVisible({ timeout: 10000 }); + + await component.unmount(); + }); }); diff --git a/opencontractserver/corpuses/services/intelligence_setup.py b/opencontractserver/corpuses/services/intelligence_setup.py index 81c0a47b5..485543caf 100644 --- a/opencontractserver/corpuses/services/intelligence_setup.py +++ b/opencontractserver/corpuses/services/intelligence_setup.py @@ -87,7 +87,13 @@ def status( *, request: Any = None, ) -> ServiceResult[IntelligenceSetupStatus]: - """Report which bundle pieces are already installed on the corpus.""" + """Report which bundle pieces are already installed on the corpus. + + Three DB queries per call (corpus fetch, reference-action exists, + installed-template names). Mounted once per corpus page load, so the + cost is negligible; revisit (e.g. a single aggregated query) if this is + ever polled or rendered per-row in the corpus list. + """ from opencontractserver.corpuses.models import Corpus, CorpusAction corpus = cls.get_or_none(Corpus, corpus_pk, user) @@ -331,6 +337,18 @@ def _setup_templates( corpus=corpus, source_template=template ).first() outcome.already_installed = action is not None + except Exception as exc: + # Any other clone failure (e.g. OperationalError, ValueError) + # must stay contained to this template — the bundle promises + # graceful partial success, so record it and move on rather + # than aborting the remaining templates with a 500. + outcome.error = f"Failed to install template: {exc}" + logger.exception( + "Intelligence setup: clone failed for %r on corpus %s", + name, + corpus.pk, + ) + continue if action is None: outcome.error = "Failed to install template." continue diff --git a/opencontractserver/tests/test_intelligence_setup.py b/opencontractserver/tests/test_intelligence_setup.py index f80b3e61c..dd2ad52fa 100644 --- a/opencontractserver/tests/test_intelligence_setup.py +++ b/opencontractserver/tests/test_intelligence_setup.py @@ -200,6 +200,121 @@ def test_status_invisible_corpus(self): result = CorpusIntelligenceSetupService.status(self.stranger, self.corpus.pk) self.assertFalse(result.ok) + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_skips_deterministic_half_without_analyzer(self, mock_start): + """No enrichment analyzer registered → LLM half still installs.""" + from opencontractserver.analyzer.models import Analyzer + + Analyzer.objects.filter( + task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK + ).delete() + + result = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(result.ok, result.error) + summary = result.value + assert summary is not None + + self.assertFalse(summary.reference_available) + self.assertFalse(summary.reference_action_installed_now) + self.assertFalse(summary.reference_analysis_started) + mock_start.assert_not_called() + # No reference action row, but the templates still installed. + self.assertFalse( + CorpusAction.objects.filter( + corpus=self.corpus, + analyzer__task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK, + ).exists() + ) + self.assertTrue(all(o.installed_now for o in summary.templates)) + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_skips_second_analysis_when_one_in_flight(self, mock_start): + """A QUEUED/RUNNING enrichment analysis suppresses a duplicate start.""" + from opencontractserver.analyzer.models import Analysis, Analyzer + from opencontractserver.types.enums import JobStatus + + analyzer = Analyzer.objects.get( + task_name=enrichment_constants.ENRICHMENT_ANALYZER_TASK + ) + Analysis.objects.create( + analyzer=analyzer, + analyzed_corpus=self.corpus, + creator=self.user, + status=JobStatus.RUNNING.value, + ) + + result = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(result.ok, result.error) + summary = result.value + assert summary is not None + + # Action installed, but no second analysis started. + self.assertTrue(summary.reference_action_installed_now) + self.assertFalse(summary.reference_analysis_started) + mock_start.assert_not_called() + + @patch(_START_ANALYSIS, side_effect=lambda *a, **k: ServiceResult.failure("boom")) + def test_setup_records_failed_analysis_start(self, mock_start): + """A failed analysis start is logged, not fatal — setup still succeeds.""" + result = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(result.ok, result.error) + summary = result.value + assert summary is not None + self.assertTrue(summary.reference_action_installed_now) + self.assertFalse(summary.reference_analysis_started) + mock_start.assert_called_once() + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_records_error_for_inactive_template(self, mock_start): + """An inactive bundle template is recorded as an error, not raised.""" + target = INTELLIGENCE_SETUP_TEMPLATE_NAMES[0] + CorpusActionTemplate.objects.filter(name=target).update(is_active=False) + + result = CorpusIntelligenceSetupService.setup(self.user, self.corpus.pk) + self.assertTrue(result.ok, result.error) + summary = result.value + assert summary is not None + + by_name = {o.template_name: o for o in summary.templates} + self.assertEqual(by_name[target].error, "Template not found or inactive.") + self.assertFalse(by_name[target].installed_now) + # The other templates still install — partial success. + for name, outcome in by_name.items(): + if name != target: + self.assertTrue(outcome.installed_now, name) + + @patch(_START_ANALYSIS, side_effect=_ok_analysis) + def test_setup_contains_clone_failure_per_template(self, mock_start): + """A non-IntegrityError clone failure stays contained to its template.""" + with patch.object( + CorpusActionTemplate, + "clone_to_corpus", + side_effect=ValueError("kaboom"), + ): + setup_result = CorpusIntelligenceSetupService.setup( + self.user, self.corpus.pk + ) + + # The whole call still succeeds (graceful partial success), and the + # deterministic reference half is unaffected. + self.assertTrue(setup_result.ok, setup_result.error) + summary = setup_result.value + assert summary is not None + self.assertTrue(summary.reference_action_installed_now) + for outcome in summary.templates: + self.assertFalse(outcome.installed_now, outcome.template_name) + self.assertTrue( + outcome.error.startswith("Failed to install template:"), + outcome.error, + ) + # No partial action rows were left installed. + self.assertFalse( + CorpusAction.objects.filter( + corpus=self.corpus, + source_template__name__in=INTELLIGENCE_SETUP_TEMPLATE_NAMES, + ).exists() + ) + class IntelligenceSetupGraphQLTestCase(TestCase): """Schema-level smoke tests via graphql_sync execution.""" From a38ca64e097d30b2c8d5dd2471ed9318baea425d Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 12 Jun 2026 00:42:50 -0500 Subject: [PATCH 06/13] Intelligence setup hardening, PDF inline citations, GraphQL validation + perf fixes, demo walkthroughs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash of six commits addressing the PR #1982 review and the defects uncovered while verifying the feature live. Intelligence setup (review fixes): - batch_run_action(allow_partial=True) queues up to BATCH_RUN_MAX_DOCS instead of refusing on large corpora; per-template remainingCount surfaces the deferred remainder in the banner toast. - Setup status gains canSetup + referenceAvailable; is_fully_set_up no longer demands deployment-unavailable pieces (zombie-CTA fix); the banner hides for viewers who cannot run setup. - setupCorpusIntelligence requires CRUD, matching AddTemplateToCorpus / CreateCorpusAction; shared CorpusActionService.install_template replaces the duplicated clone recipe; reference action dedupe via get_or_create + GovernanceGraphLive consults setup status before installing; post-create chain surfaces ok=false; lookup-only EnrichmentService.get_analyzer; redundant queries trimmed. PDF inline citations: - Enrichment mentions on PDF documents are projected onto PAWLs token bounding boxes via PlasmaPDF (shared opencontractserver/utils/ span_projection.py, also used by datacell grounding) — TOKEN_LABEL with real page numbers instead of unpaintable char-offset spans. Whitespace-insensitive ordinal-occurrence remapping absorbs txt-extract/PAWLs drift; legacy span mentions upgrade in place on re-enrichment. useReferenceMentions fixed (scoped analyzedCorpusId discovery, client.query instead of a hanging looped useLazyQuery, lean GET_REFERENCE_MENTIONS_FOR_ANALYSIS selection — ~176s -> ~0s). GraphQL spec validation + performance: - validation_rules now extend (not replace) graphql-core's spec rules — unknown-argument/field and variable-type validation had been silently disabled in every environment. All 26 invalid frontend documents repaired, including silently-broken features: new DeleteMetadataColumn and UpdateFieldset mutations (BaseService pattern, IDOR-unified messages), corpus chat history shape fix, tokenAuth unified on the WithUser payload, redirect corpus context sourced from route slugs. CI sweep: tests/architecture/test_frontend_graphql_documents.py + scripts/validate_frontend_graphql.py. - Presigned-URL cache lifetime now derives from AWS_QUERYSTRING_EXPIRE (was the 7-day CacheControl max-age) and is clamped to half the signature lifetime — cached file links could 403 for hours. - UserFeedbackQuerySet.visible_to_user inherited-visibility rewritten as a correlated Exists (was an uncorrelated IN materializing the full annotations table per evaluation): GetAnnotationsForAnalysis measured 176s -> 2.3s for a 108-mention document. Branding + docs: - Navbar wordmark [cite] -> [OpenContracts] (serif treatment kept, 19px, aria-label aligned). - README leads with two captioned demo walkthrough GIFs (docs/assets/images/gifs/demo-{1,2}-*.gif): one-click corpus intelligence setup and the explore-and-ask tour with inline statutory citations. --- README.md | 41 +++- changelog.d/1982-review-fixes.fixed.md | 49 ++++ changelog.d/graphql-spec-validation.fixed.md | 48 ++++ changelog.d/pdf-inline-citations.fixed.md | 48 ++++ config/graphql/corpus_mutations.py | 39 +-- config/graphql/corpus_types.py | 24 +- config/graphql/extract_mutations.py | 90 +++++++ config/graphql/mutations.py | 19 +- config/graphql/schema.py | 20 +- config/settings/base.py | 28 ++- .../images/gifs/demo-1-create-and-setup.gif | Bin 0 -> 3557868 bytes .../images/gifs/demo-2-explore-and-ask.gif | Bin 0 -> 9762418 bytes .../__tests__/id-based-navigation.test.tsx | 15 +- .../admin/global_agent_management.graphql.ts | 4 +- .../src/components/cookies/CookieConsent.tsx | 4 +- .../intelligence/GovernanceGraphLive.tsx | 52 ++-- .../intelligence/IntelligenceSetupBanner.tsx | 29 ++- .../src/components/corpuses/CorpusMapView.tsx | 2 +- .../documents/VersionHistoryPanel.tsx | 2 +- .../document_kb/useReferenceMentions.ts | 53 ++-- frontend/src/components/layout/NavMenu.tsx | 17 +- .../src/components/maps/DiscoverMapPanel.tsx | 2 +- frontend/src/graphql/mutations.ts | 75 ++---- frontend/src/graphql/queries.ts | 182 +++++--------- .../src/hooks/useNavigateToDocumentById.ts | 10 +- frontend/src/routing/CentralRouteManager.tsx | 7 +- frontend/src/views/Corpuses.tsx | 16 +- .../DocumentKnowledgeBaseCorpusless.ct.tsx | 1 - frontend/tests/GovernanceGraph.ct.tsx | 87 ++++++- frontend/tests/IntelligenceSetupBanner.ct.tsx | 80 +++++- .../corpuses/services/corpus_actions.py | 116 +++++++-- .../corpuses/services/intelligence_setup.py | 230 +++++++++++------- .../enrichment/services/enrichment_service.py | 18 +- opencontractserver/enrichment/writer.py | 214 +++++++++++++++- opencontractserver/shared/QuerySets.py | 19 +- .../test_frontend_graphql_documents.py | 46 ++++ .../tests/test_batch_run_corpus_action.py | 38 +++ .../test_enrichment_analyzer_integration.py | 14 ++ .../tests/test_enrichment_writer.py | 215 ++++++++++++++++ .../tests/test_extract_mutations.py | 69 ++++++ opencontractserver/tests/test_feedback.py | 26 ++ opencontractserver/tests/test_files_utils.py | 31 +++ .../tests/test_intelligence_setup.py | 111 ++++++++- .../tests/test_metadata_columns_graphql.py | 69 ++++++ .../tests/test_security_hardening.py | 49 ++++ .../utils/extraction_grounding.py | 84 ++----- opencontractserver/utils/files.py | 15 ++ opencontractserver/utils/span_projection.py | 101 ++++++++ scripts/validate_frontend_graphql.py | 120 +++++++++ 49 files changed, 2119 insertions(+), 510 deletions(-) create mode 100644 changelog.d/1982-review-fixes.fixed.md create mode 100644 changelog.d/graphql-spec-validation.fixed.md create mode 100644 changelog.d/pdf-inline-citations.fixed.md create mode 100644 docs/assets/images/gifs/demo-1-create-and-setup.gif create mode 100644 docs/assets/images/gifs/demo-2-explore-and-ask.gif create mode 100644 opencontractserver/tests/architecture/test_frontend_graphql_documents.py create mode 100644 opencontractserver/utils/span_projection.py create mode 100644 scripts/validate_frontend_graphql.py diff --git a/README.md b/README.md index b31be26ac..52b7e614e 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,39 @@ findings = await agent.structured_response( [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/JSv4) | | | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Backend coverage | [![backend](https://codecov.io/gh/Open-Source-Legal/OpenContracts/branch/main/graph/badge.svg?flag=backend&token=RdVsiuaTVz)](https://app.codecov.io/gh/Open-Source-Legal/OpenContracts?flags%5B0%5D=backend) | | Frontend coverage | [![frontend](https://codecov.io/gh/Open-Source-Legal/OpenContracts/branch/main/graph/badge.svg?flag=frontend&token=RdVsiuaTVz)](https://app.codecov.io/gh/Open-Source-Legal/OpenContracts?flags%5B0%5D=frontend) | | Meta | [![code style - black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![types - Mypy](https://img.shields.io/badge/types-Mypy-blue.svg)](https://github.com/python/mypy) [![imports - isort](https://img.shields.io/badge/imports-isort-ef8336.svg)](https://github.com/pycqa/isort) [![License - MIT](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT) | --- -![Discovery Landing Page](docs/assets/images/screenshots/auto/landing--discovery-page--anonymous.png) +## From documents to a citation graph — in about a minute + +Create a corpus, drop in your documents, and click **Set up**. That one click installs the +intelligence bundle: agents describe and summarize every document, and the reference web +starts weaving — every statutory citation detected, resolved, and drawn as an edge. + +![Create a corpus and set up collection intelligence in one click](docs/assets/images/gifs/demo-1-create-and-setup.gif) + +By the end of the clip, 36 SEC filings are a navigable graph — wired to the Delaware +General Corporation Law, the Securities Act, and the SEC rules they cite, section by +section. Law the library doesn't hold yet isn't dropped on the floor: it's tracked as a +backlog, automatically, until you ingest it. + +### Then explore it — and ask it questions + +Citations are highlighted inline on the filings themselves. The References panel lists +everything a document cites — click any cite to open the statute, with its own +cross-references and everything that cites it back. The ask bar runs a corpus-scoped +agent whose answers come back grounded and cited. + +![Explore the citation graph — inline citations, the references panel, and grounded answers](docs/assets/images/gifs/demo-2-explore-and-ask.gif) + +Everything in both clips is the stock product against a local install — no custom code, +and every surface the UI touches is also reachable over the API and MCP server below. + +--- ## Build on it @@ -189,7 +214,9 @@ This is the DRY principle applied to the citation graph: annotate once, build on --- -## See it in Action +## Annotation flows + +The human side of the graph — precise, layout-faithful annotation on PDFs and text: ### PDF Annotation Flow @@ -240,10 +267,10 @@ docker compose -f production.yml up -d The discover/landing page and the `/about` page are driven by a JSON content pack so deployers can retarget the messaging without forking the codebase. Two variants ship in the repo: -| Variant key | Framing | Best fit | -| --------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------- | -| `default` | _Open-source document intelligence you can build on._ | The OSS project's repo and most self-hosted deployments — developer-facing. | -| `public-record` | _The citation layer underneath the public record._ | End-user deployments curating public-domain documents (named-incumbents pitch). | +| Variant key | Framing | Best fit | +| --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------- | +| `default` | _Open-source document intelligence you can build on._ | The OSS project's repo and most self-hosted deployments — developer-facing. | +| `public-record` | _The citation layer underneath the public record._ | End-user deployments curating public-domain documents (named-incumbents pitch). | Switch variants at runtime by setting `REACT_APP_LANDING_VARIANT` in `frontend/public/env-config.js` — no rebuild required. Unknown variant keys fall back to `default`. diff --git a/changelog.d/1982-review-fixes.fixed.md b/changelog.d/1982-review-fixes.fixed.md new file mode 100644 index 000000000..d1b84d28b --- /dev/null +++ b/changelog.d/1982-review-fixes.fixed.md @@ -0,0 +1,49 @@ +- **Intelligence setup: large corpora no longer silently skip enrichment.** + `CorpusActionService` gained `batch_run_action(user, action, allow_partial=)` + (`opencontractserver/corpuses/services/corpus_actions.py`) — the trusted-caller + variant the one-click setup now uses with `allow_partial=True`, queuing the + first `BATCH_RUN_MAX_DOCS` documents (deterministic id order) instead of + refusing outright when a corpus exceeds the per-call cap. The per-template + outcome (`TemplateSetupOutcome.remaining_count`, exposed as `remainingCount` + on `IntelligenceTemplateOutcomeType`) reports the deferred remainder and the + banner toast surfaces it. Previously a 250-doc corpus got a success toast, a + permanently hidden banner, and zero documents enriched. +- **Intelligence-setup status no longer demands deployment-unavailable pieces.** + `IntelligenceSetupStatus.is_fully_set_up` + (`opencontractserver/corpuses/services/intelligence_setup.py`) excludes the + reference action when no enrichment analyzer is registered + (`reference_available`, new on the status payload) and excludes bundle + templates that are unseeded/inactive deployment-wide — either condition + previously made the setup CTA an undismissable zombie whose every click + toasted success. +- **Setup CTA hidden from viewers who can't run it.** The status payload gained + `can_setup` (mirrors the mutation's permission gate); + `IntelligenceSetupBanner.tsx` renders nothing unless `canSetup` — read-only + and anonymous viewers of a public not-set-up corpus previously saw a + guaranteed-to-fail "Set up" button. +- **Permission tier harmonized to CRUD.** `setupCorpusIntelligence` (service + + mutation docstrings, `config/graphql/corpus_mutations.py`) now requires CRUD + on the corpus — the tier `AddTemplateToCorpus` and `CreateCorpusAction` + already gate the identical writes at; it previously required only UPDATE, a + weaker path to the same row installs. +- **Reference action can no longer be double-installed.** The governance + graph's "Map the reference web" bootstrap + (`GovernanceGraphLive.tsx`) consults `corpusIntelligenceSetupStatus` and + skips `createCorpusAction` when the add_document reference action already + exists (a duplicate row would run the enrichment analyzer twice on every + future upload); the server side switched to `get_or_create` to narrow the + concurrent-race window. +- **Post-create setup opt-in surfaces soft failures.** `Corpuses.tsx` now + inspects the resolved `setupCorpusIntelligence.ok` and shows the + "couldn't start" toast — an `ok=false` envelope was previously discarded, + leaving users to believe enrichment was running. +- **Setup warning toast names the actual failures.** The banner aggregates + `templates[].error` into the warning instead of a generic guess. +- **Dedup/cleanup.** Template installs go through a single shared + `CorpusActionService.install_template` (dedupe fast-path, savepoint clone, + IntegrityError recovery, CRUD grant) used by both `AddTemplateToCorpus` and + the bundle; the enrichment analyzer lookup goes through the new lookup-only + `EnrichmentService.get_analyzer()` next to the converge logic; setup + prefetches bundle templates with `name__in` and derives + `total_active_documents` from the batch summary instead of a redundant + corpus-document count. diff --git a/changelog.d/graphql-spec-validation.fixed.md b/changelog.d/graphql-spec-validation.fixed.md new file mode 100644 index 000000000..f8588dbb1 --- /dev/null +++ b/changelog.d/graphql-spec-validation.fixed.md @@ -0,0 +1,48 @@ +- **GraphQL spec validation restored on the served endpoint (security).** + ``GraphQLView(validation_rules=[DepthLimit…])`` REPLACED graphql-core's + spec rule set (that is ``validate()``'s semantics for an explicit rules + list), silently disabling every standard GraphQL validation — unknown + arguments/fields and variable-type checks — in all environments. Invalid + queries executed with the bogus parts ignored, which let ~26 invalid + frontend documents ship unnoticed (several backing silently-broken + features). ``config/graphql/schema.py`` now builds + ``[*specified_rules, DepthLimitValidationRule(, DisableIntrospection)]``, + pinned by ``test_security_hardening.TestServedValidationRulesIncludeSpecRules``. + Every shipped frontend document is now swept in CI by + ``opencontractserver/tests/architecture/test_frontend_graphql_documents.py`` + (ad-hoc: ``scripts/validate_frontend_graphql.py``; the sweep strips Apollo + ``@client`` selections and skips fragment-only/interpolated documents). +- **All 26 invalid frontend documents repaired**, including features that + could never have worked: ``deleteMetadataColumn`` and ``updateFieldset`` + were called by the UI but did not exist server-side (both now implemented + in ``config/graphql/extract_mutations.py`` via the BaseService + get_or_none/require_permission pattern with IDOR-unified messages); + ``GET_CORPUS_CHAT_MESSAGES`` used a misspelled argument + relay shape on a + plain list field (corpus chat history always loaded empty objects); + ``tokenAuth`` was schema-conditional on ``USE_AUTH0`` (now always the + ``WithUser`` payload, so the login document validates everywhere); the + document-by-id redirect selected the nonexistent ``DocumentType.corpus`` + (corpus context now sourced from the route's slug resolution where it + exists — the previous mock-only field meant graph-node click-throughs + always landed on standalone paths); dead ``ADD_DOCUMENT_TO_CORPUS`` + removed; plus variable-type (ID!/String!, JSONString/GenericScalar, + String/enum) and payload-field corrections across vote, thread-moderation, + research-report, TOC and corpus-list documents. +- **Presigned file URLs no longer outlive their signatures.** The AWS + settings branch derived the shared file-URL cache lifetime from + ``_AWS_EXPIRY`` (the stored objects' HTTP CacheControl max-age, 7 days) + instead of the presign lifetime (``AWS_QUERYSTRING_EXPIRE``, 1 hour), so + redis served dead 403 pdf/pawls/txt links for up to 5 hours. + ``AWS_QUERYSTRING_EXPIRE`` is now explicit, the cache TTL derives from it, + and ``clamp_shared_url_cache_ttl`` (``opencontractserver/utils/files.py``) + enforces TTL ≤ half the signature lifetime even against env overrides. +- **3-minute analysis-annotation responses fixed.** + ``UserFeedbackQuerySet.visible_to_user`` expressed annotation-inherited + visibility as ``commented_annotation_id__in=`` — an uncorrelated ``IN`` materialized over the entire + annotations table on every evaluation (~0.8s each; 216 pagination counts + made ``GetAnnotationsForAnalysis`` take ~176s for a 108-mention document). + Rewritten as a correlated ``Exists`` pinned to the feedback row's + annotation id — identical semantics (permissioning invariant suites pass), + measured 176s → 2.3s. Shape pinned by + ``test_feedback.TestVisibilityQueryShape``. diff --git a/changelog.d/pdf-inline-citations.fixed.md b/changelog.d/pdf-inline-citations.fixed.md new file mode 100644 index 000000000..b91253da8 --- /dev/null +++ b/changelog.d/pdf-inline-citations.fixed.md @@ -0,0 +1,48 @@ +- **Inline citations now render on PDF documents.** The enrichment writer + (`opencontractserver/enrichment/writer.py`) projected nothing visible on + PDFs: it stored every reference mention as a `SPAN_LABEL` char-offset + annotation, which the PDF viewer (token-indexed PAWLs renderer) cannot + paint — citations showed in the References panel but never inline on the + filings themselves. Mentions on PDF documents are now projected onto PAWLs + token bounding boxes via PlasmaPDF (`TOKEN_LABEL`, real page numbers + instead of the hardcoded `page=1`), with the char span preserved in + `data.char_span` for dedupe. Projection handles real-ingest drift between + `txt_extract_file` and the PAWLs text via whitespace-insensitive + ordinal-occurrence remapping (covers hard line-wraps and see-quoted SECTION + refs whose raw text extends left of the span start) and falls back to the + span representation when the mention text genuinely is not in the PAWLs + text. Re-running enrichment upgrades pre-fix span mentions **in place** + (same row — `CorpusReference`/`Relationship` FKs survive), so a corpus + re-enrich is also the backfill. +- **Shared span→token projection utility.** The format-aware document-text + loader and the PlasmaPDF span projection moved from private helpers in + `opencontractserver/utils/extraction_grounding.py` to + `opencontractserver/utils/span_projection.py` + (`load_document_text_and_layer`, `project_span_to_token_annotation`); + datacell grounding and the enrichment writer now share one implementation. +- **Reference-mention merge fixed and made usable.** + `useReferenceMentions` (frontend): (1) the analyses discovery query used + `analyses(corpusId:)` — an argument that does not exist in the schema and + was silently ignored (see validation gap below), so the hook swept every + enrichment analysis platform-wide; it now uses the real + `analyzedCorpusId` filter. (2) The per-analysis fetch used a `useLazyQuery` + handle re-executed in a loop, whose promise was observed never settling — + replaced with `client.query`. (3) The fetch now uses a lean + `GET_REFERENCE_MENTIONS_FOR_ANALYSIS` selection: the previous full + selection (per-annotation userFeedback / relationships / document / corpus) + measured **~176s** server-side for 108 mentions vs ~0s for the lean one. + Net effect: inline cites appear within seconds of opening a PDF. +- **Known issues surfaced during this work (deliberately NOT fixed here):** + (1) `GraphQLView(validation_rules=[DepthLimit…])` REPLACES graphql-core's + spec rule set, so standard GraphQL validation (unknown arguments/fields, + variable types) is disabled on the served endpoint; ~34 shipped frontend + documents currently fail spec validation (`scripts/validate_frontend_graphql.py` + enumerates them) — restoring `[*specified_rules, …]` must land with those + query fixes (documented in `config/graphql/schema.py`). (2) Presigned file + URLs are cached for `FILE_URL_SHARED_CACHE_TTL=21600`s while + `AWS_QUERYSTRING_EXPIRE` defaults to 3600s — cached links 403 for hours. + (3) `Document.update_summary`'s docstring claims it updates + `md_summary_file` but it only writes a revision, so intelligence-panel + summary coverage stays 0%. (4) `add_document`-triggered agent actions + re-fire on agent-authored document writes (runaway agent loop / unbounded + LLM spend). diff --git a/config/graphql/corpus_mutations.py b/config/graphql/corpus_mutations.py index 75b45a768..489e72c0e 100644 --- a/config/graphql/corpus_mutations.py +++ b/config/graphql/corpus_mutations.py @@ -8,7 +8,7 @@ import graphene from django.conf import settings from django.core.exceptions import PermissionDenied -from django.db import DatabaseError, IntegrityError, transaction +from django.db import DatabaseError, transaction from django.utils import timezone from graphql_jwt.decorators import login_required, user_passes_test from graphql_relay import from_global_id, to_global_id @@ -1604,38 +1604,22 @@ def mutate(root, info, template_id: str, corpus_id: str) -> "AddTemplateToCorpus # Get the template (templates are global, no user filter needed) template = CorpusActionTemplate.objects.get(pk=template_pk, is_active=True) - # Fast-path duplicate check (avoids wasted clone + rollback). - # The unique constraint + IntegrityError catch below handles the - # race-condition window between this check and the insert. - if CorpusAction.objects.filter( - corpus=corpus, source_template=template - ).exists(): - return AddTemplateToCorpus( - ok=False, - message="This template has already been added to the corpus", - obj=None, - ) + # Shared install recipe (dedupe fast-path, savepoint-wrapped + # clone, IntegrityError race recovery, CRUD grant) — the same + # method the one-click intelligence setup uses, so the two + # install paths cannot drift. + from opencontractserver.corpuses.services import CorpusActionService - # Clone the template into a CorpusAction. - # Wrap in a savepoint so that a race-condition IntegrityError - # does not abort the outer transaction (PostgreSQL requirement). - try: - with transaction.atomic(): - action = template.clone_to_corpus(corpus, creator=user) - except IntegrityError: + action, created = CorpusActionService.install_template( + user, corpus, template, request=info.context + ) + if not created: return AddTemplateToCorpus( ok=False, message="This template has already been added to the corpus", obj=None, ) - set_permissions_for_obj_to_user( - user, - action, - [PermissionTypes.CRUD], - request=info.context, - ) - return AddTemplateToCorpus( ok=True, message="Template added to corpus successfully", @@ -1664,7 +1648,8 @@ class SetupCorpusIntelligence(graphene.Mutation): and starts the first weave (deterministic), then clones the description + summary action templates and batch-runs each over every document already in the corpus (LLM). Safe to repeat — every step skips work that already - exists. Requires UPDATE permission on the corpus. + exists. Requires CRUD permission on the corpus — the tier + AddTemplateToCorpus and CreateCorpusAction gate the identical writes at. """ class Arguments: diff --git a/config/graphql/corpus_types.py b/config/graphql/corpus_types.py index 3e6100207..4b1e5ba16 100644 --- a/config/graphql/corpus_types.py +++ b/config/graphql/corpus_types.py @@ -978,6 +978,13 @@ class IntelligenceTemplateOutcomeType(graphene.ObjectType): required=True, description="Per-template failure (empty string when the step succeeded).", ) + remaining_count = graphene.Int( + required=True, + description=( + "Documents deferred past the per-call batch cap — re-run setup " + "(or wait for the add_document trigger) to process them." + ), + ) class CorpusIntelligenceSetupSummaryType(graphene.ObjectType): @@ -1006,6 +1013,10 @@ class CorpusIntelligenceSetupSummaryType(graphene.ObjectType): class CorpusIntelligenceSetupStatusType(graphene.ObjectType): """Which intelligence-bundle pieces a corpus already has installed.""" + reference_available = graphene.Boolean( + required=True, + description="The reference-enrichment analyzer is registered on this deployment.", + ) reference_action_installed = graphene.Boolean(required=True) installed_template_names = graphene.List( graphene.NonNull(graphene.String), required=True @@ -1015,5 +1026,16 @@ class CorpusIntelligenceSetupStatusType(graphene.ObjectType): ) is_fully_set_up = graphene.Boolean( required=True, - description="Reference action installed and no bundle template missing.", + description=( + "Every deployment-installable bundle piece is installed " + "(unavailable pieces — unregistered analyzer, inactive template — " + "are excluded)." + ), + ) + can_setup = graphene.Boolean( + required=True, + description=( + "The requesting user holds the permission setupCorpusIntelligence " + "requires (CRUD) — drives the setup CTA's visibility." + ), ) diff --git a/config/graphql/extract_mutations.py b/config/graphql/extract_mutations.py index 74fcd69c2..05ca123ca 100644 --- a/config/graphql/extract_mutations.py +++ b/config/graphql/extract_mutations.py @@ -355,6 +355,53 @@ def mutate(root, info, column_id, **kwargs) -> "UpdateMetadataColumn": ) +class DeleteMetadataColumn(graphene.Mutation): + """Delete a manual-entry metadata column definition (values cascade).""" + + class Arguments: + column_id = graphene.ID(required=True) + + ok = graphene.Boolean() + message = graphene.String() + + @login_required + def mutate(root, info, column_id) -> "DeleteMetadataColumn": + from opencontractserver.types.enums import PermissionTypes + + # Unified message blocks IDOR enumeration: same response whether the + # column does not exist or the caller lacks DELETE permission. + not_found_msg = "Column not found or you do not have permission to delete it." + + try: + user = info.context.user + column = BaseService.get_or_none( + Column, from_global_id(column_id)[1], user, request=info.context + ) + if column is None or BaseService.require_permission( + column, user, PermissionTypes.DELETE, request=info.context + ): + return DeleteMetadataColumn(ok=False, message=not_found_msg) + + # Mirrors UpdateMetadataColumn: only manual-entry (metadata) + # columns are managed through this surface — extract columns + # have their own lifecycle (DeleteColumn). + if not column.is_manual_entry: + return DeleteMetadataColumn( + ok=False, message="Only manual entry columns can be deleted" + ) + + column.delete() + return DeleteMetadataColumn( + ok=True, message="Metadata field deleted successfully" + ) + + except Exception: + logger.exception("Error deleting metadata field") + return DeleteMetadataColumn( + ok=False, message="Error deleting metadata field." + ) + + class SetMetadataValue(graphene.Mutation): """Set a metadata value for a document. @@ -541,6 +588,49 @@ def mutate(root, info, name, description) -> "CreateFieldset": return CreateFieldset(ok=True, message="SUCCESS!", obj=fieldset) +class UpdateFieldset(graphene.Mutation): + """Rename / re-describe a fieldset the caller may UPDATE.""" + + class Arguments: + id = graphene.ID(required=True) + name = graphene.String(required=False) + description = graphene.String(required=False) + + ok = graphene.Boolean() + message = graphene.String() + obj = graphene.Field(FieldsetType) + + @login_required + def mutate(root, info, id, name=None, description=None) -> "UpdateFieldset": + from opencontractserver.types.enums import PermissionTypes + + # Unified message blocks IDOR enumeration: same response whether the + # fieldset does not exist or the caller lacks UPDATE permission. + not_found_msg = "Fieldset not found or you do not have permission to update it." + + try: + user = info.context.user + fieldset = BaseService.get_or_none( + Fieldset, from_global_id(id)[1], user, request=info.context + ) + if fieldset is None or BaseService.require_permission( + fieldset, user, PermissionTypes.UPDATE, request=info.context + ): + return UpdateFieldset(ok=False, message=not_found_msg) + + if name is not None: + fieldset.name = name + if description is not None: + fieldset.description = description + fieldset.save() + + return UpdateFieldset(ok=True, message="SUCCESS!", obj=fieldset) + + except Exception: + logger.exception("Error updating fieldset") + return UpdateFieldset(ok=False, message="Error updating fieldset.") + + class UpdateColumnMutation(DRFMutation): class Arguments: name = graphene.String(required=False) diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index 4b4d6d288..abc9a043d 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -7,7 +7,6 @@ import graphene import graphql_jwt -from django.conf import settings # Import agent mutations from config.graphql.agent_mutations import ( @@ -140,6 +139,7 @@ CreateMetadataColumn, DeleteColumn, DeleteExtract, + DeleteMetadataColumn, DeleteMetadataValue, EditDatacell, RejectDatacell, @@ -149,6 +149,7 @@ StartExtract, UpdateColumnMutation, UpdateExtractMutation, + UpdateFieldset, UpdateMetadataColumn, ) @@ -245,11 +246,15 @@ class Mutation(graphene.ObjectType): - # TOKEN MUTATIONS (IF WE'RE NOT OUTSOURCING JWT CREATION TO AUTH0) ####### - if not settings.USE_AUTH0: - token_auth = ObtainJSONWebTokenWithUser.Field() - else: - token_auth = graphql_jwt.ObtainJSONWebToken.Field() + # TOKEN MUTATIONS ######################################################### + # Always the ``WithUser`` payload: gating the field's TYPE on USE_AUTH0 + # made the schema change shape per deployment, so the frontend's + # LOGIN_MUTATION (which selects ``user``) was schema-INVALID on Auth0 + # deployments — harmless only while spec validation was disabled. Under + # Auth0 this mutation is simply never used (the frontend gates login on + # REACT_APP_USE_AUTH0, and password auth is rejected by the backends), + # but it stays schema-valid everywhere. + token_auth = ObtainJSONWebTokenWithUser.Field() verify_token = graphql_jwt.Verify.Field() refresh_token = graphql_jwt.Refresh.Field() @@ -373,6 +378,7 @@ class Mutation(graphene.ObjectType): # EXTRACT MUTATIONS ########################################################## create_fieldset = CreateFieldset.Field() + update_fieldset = UpdateFieldset.Field() create_column = CreateColumn.Field() update_column = UpdateColumnMutation.Field() @@ -396,6 +402,7 @@ class Mutation(graphene.ObjectType): # NEW METADATA MUTATIONS (Column/Datacell based) ################################ create_metadata_column = CreateMetadataColumn.Field() update_metadata_column = UpdateMetadataColumn.Field() + delete_metadata_column = DeleteMetadataColumn.Field() set_metadata_value = SetMetadataValue.Field() delete_metadata_value = DeleteMetadataValue.Field() diff --git a/config/graphql/schema.py b/config/graphql/schema.py index da2594e63..8d8fefb8b 100644 --- a/config/graphql/schema.py +++ b/config/graphql/schema.py @@ -1,15 +1,29 @@ import graphene from django.conf import settings +from graphql.validation import specified_rules from config.graphql.mutations import Mutation from config.graphql.queries import Query from config.graphql.security import DepthLimitValidationRule, DisableIntrospection -# Build validation rules: always enforce depth limits, disable introspection -# in production. +# Build validation rules: the FULL GraphQL spec rule set, plus depth limiting +# always and introspection disabling in production. +# +# The spec rules MUST be listed explicitly: graphql-core's +# ``validate(schema, document, rules)`` REPLACES the default rule set when +# ``rules`` is provided. Passing only the custom hardening rules silently +# disabled every standard validation (unknown arguments/fields, variable +# type checks, ...) on the served endpoint — invalid queries executed with +# the bogus parts ignored instead of erroring, which let ~26 invalid +# frontend documents ship unnoticed. Pinned by +# ``test_security_hardening.TestServedValidationRulesIncludeSpecRules``; the +# frontend documents themselves are swept by +# ``tests/architecture/test_frontend_graphql_documents.py`` (and +# ``scripts/validate_frontend_graphql.py`` for ad-hoc runs). +# # NOTE: This list is built at import time. Tests that override settings.DEBUG # after import must use graphql-core's validate() directly with the rule classes. -validation_rules: list = [DepthLimitValidationRule] +validation_rules: list = [*specified_rules, DepthLimitValidationRule] if not settings.DEBUG: validation_rules.append(DisableIntrospection) diff --git a/config/settings/base.py b/config/settings/base.py index 629d230f3..717ac6be8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -393,7 +393,14 @@ AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", default="dummy-bucket") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_QUERYSTRING_AUTH = True + # Presigned-URL signature lifetime (seconds). Made explicit (rather than + # relying on django-storages' implicit 3600 default) because the shared + # file-URL cache TTL below MUST be derived from it. + AWS_QUERYSTRING_EXPIRE = env.int("AWS_QUERYSTRING_EXPIRE", default=3600) # DO NOT change these unless you know what you're doing. + # NOTE: this is the HTTP CacheControl max-age for the stored OBJECTS — + # it has nothing to do with how long presigned URLs stay valid (that is + # AWS_QUERYSTRING_EXPIRE above). _AWS_EXPIRY = 60 * 60 * 24 * 7 # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_S3_OBJECT_PARAMETERS = { @@ -533,15 +540,28 @@ # window. The TTL is held well under the signed-URL lifetime so a cached URL is # always served with ample validity remaining. 0 disables the shared cache # (LOCAL storage URLs are relative + free; only the per-request memo applies). +from opencontractserver.utils.files import ( # noqa: E402 (pure helper, no app/model imports at module level) + clamp_shared_url_cache_ttl as _clamp_shared_url_cache_ttl, +) + if STORAGE_BACKEND == "GCP": _signed_url_lifetime_seconds = int(GS_EXPIRATION.total_seconds()) elif STORAGE_BACKEND == "AWS": - _signed_url_lifetime_seconds = _AWS_EXPIRY + # The PRESIGN lifetime (AWS_QUERYSTRING_EXPIRE) — NOT ``_AWS_EXPIRY``, + # which is the stored objects' HTTP CacheControl max-age (7 days) and + # says nothing about signature validity. Deriving from the wrong value + # let this cache serve dead (403) links for up to 5 hours. + _signed_url_lifetime_seconds = AWS_QUERYSTRING_EXPIRE else: _signed_url_lifetime_seconds = 0 -FILE_URL_SHARED_CACHE_TTL = env.int( - "FILE_URL_SHARED_CACHE_TTL", - default=max(0, min(_signed_url_lifetime_seconds // 2, 6 * 60 * 60)), +# Clamped even when set explicitly via env: a TTL beyond half the signature +# lifetime can only ever serve expired links. +FILE_URL_SHARED_CACHE_TTL = _clamp_shared_url_cache_ttl( + env.int( + "FILE_URL_SHARED_CACHE_TTL", + default=max(0, min(_signed_url_lifetime_seconds // 2, 6 * 60 * 60)), + ), + _signed_url_lifetime_seconds, ) # Max concurrent signBlob round trips when ``FileUrlPrewarmMiddleware`` pre-signs diff --git a/docs/assets/images/gifs/demo-1-create-and-setup.gif b/docs/assets/images/gifs/demo-1-create-and-setup.gif new file mode 100644 index 0000000000000000000000000000000000000000..8f7e0a28b3b2c5e15ee3415187bad1861ec923af GIT binary patch literal 3557868 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

%--yu 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�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