diff --git a/AGENTS.md b/AGENTS.md index c927f9e61..a58bb01a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,6 +332,9 @@ v0.88.0). Do not invent a theta. Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0). Do not invent the earlier sentence when no revision covers the cutoff. +Global Ask uses the same revision cover when `knowledge_cutoff` is set +(ADR 0216 / #271); omit the field to keep the live-query contract, and +never substitute a live body for a missing historical cover. A corporate-entity similarity result has three outcomes: unique, miss, or tie (ADR 0026). A tie is not a miss. Keep the organization name diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1f1c68f02..df6449854 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -240,7 +240,9 @@ Each direct edge includes `interval_relation_code` / `interval_relation_label` computed from the posts' observed windows. Global Ask merges cited threads from one post/edge fetch pair and caps the payload at the landing node bound, keeping cited posts first -(ADR 0169). Open a cited post to read the focused thread. +(ADR 0169). Optional `knowledge_cutoff` on `POST /api/ask` selects the +covering `source_post_revision` and never substitutes a live body +(ADR 0216). Open a cited post to read the focused thread. `POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over every `source_post` and atomically rewrites edges, channel signals, and Allen interval relations. Reconstruct grouping is diff --git a/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md b/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md new file mode 100644 index 000000000..fe685c470 --- /dev/null +++ b/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md @@ -0,0 +1,6 @@ +# 2.19.0 Global Ask knowledge cutoff + +Ask Agent now accepts an optional UTC knowledge cutoff. Dated questions use +the retained source-post revision from that clock, never the live rewrite, +and say when a historical body was not kept. Leaving the cutoff blank keeps +the live-query contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index e59db82d9..950e1eed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ All notable changes to this project are documented here. Format follows ### Added - - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and @@ -63,6 +62,11 @@ All notable changes to this project are documented here. Format follows deterministic application read model (`lineageweave.worker_function_taxonomy`) exposes fail-closed lookups; ranks are scale positions and are never used as weights. +======= +- Global Ask accepts an optional UTC `knowledge_cutoff`. Dated questions + retrieve only posts available by that clock, cite the retained + `source_post_revision`, and name when a historical body was not kept. + Omitting the cutoff keeps the live-query contract (ADR 0216 / #271). - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/CLAUDE.md b/CLAUDE.md index eb9e85eab..06f90d0ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,8 @@ Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite, with both clocks named. Compare those two texts before treating the live body as reconstructed evidence; do not invent an earlier sentence when no revision covers the -cutoff. +cutoff. Global Ask optional `knowledge_cutoff` uses the same cover +(ADR 0216). ## Where the rest lives diff --git a/backend/app/main.py b/backend/app/main.py index c16ca2ec7..91517e9eb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3161,6 +3161,9 @@ async def ask_agent( polls ``GET /api/ask/jobs/{id}`` for the settled answer. Submission still fails fast on the states that cannot ever succeed (blank question, missing permission, unconfigured orchestrator). + + Optional ``knowledge_cutoff`` selects retained evidence available at + that clock. Omitting it keeps the live-query contract (ADR 0216). """ return await submit_global_ask( pool=pool, diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4a208c13c..5e17c9696 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -216,7 +216,6 @@ def _seoul_today() -> date: _POST_CHAT_CANDIDATE_LIMIT = 32 - def _source_hint_facts(row: Any) -> tuple[str, ...]: """Render raw source fields as explicitly weak, column-level evidence.""" facts: list[str] = [] @@ -834,6 +833,8 @@ async def gather_global_chat_sources( # `find_linked_post_ids`'s `.direct` set used by the post-scoped chat # flow. Only the top match is expanded so lower-ranked semantic candidates # cannot each pull a separate lineage chain into the bounded context. + # Cutoff answers skip this expansion: reconstructed edges have no + # available-time contract and must not leak later neighbors (ADR 0216). lineage_neighbor_ids: list[str] = [] lineage_anchor_id = candidate_ids[0] if candidate_ids else None if lineage_anchor_id and knowledge_cutoff is None: diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py index 3b51e09d3..70953cfd2 100644 --- a/backend/app/source_post_revision.py +++ b/backend/app/source_post_revision.py @@ -100,7 +100,7 @@ async def fetch_known_at_revisions( ) -> dict[str, dict[str, str]]: """Batch-load the retained revision covering ``as_of`` for each post. - Missing posts stay absent so callers can report an honest historical-body + Missing covers are omitted so callers can report an honest historical-body limitation without substituting the live title or body. """ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9ffb1920a..860fc6588 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; +import { optionalKnowledgeCutoffIso } from "./api"; import { setLocale } from "./i18n"; import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; @@ -27,6 +28,16 @@ beforeEach(() => { }; }); +it("normalizes valid knowledge cutoffs and rejects invalid input", () => { + expect(optionalKnowledgeCutoffIso("")).toBeUndefined(); + expect(optionalKnowledgeCutoffIso("2026-01-15T12:00")).toBe( + new Date("2026-01-15T12:00").toISOString(), + ); + expect(() => optionalKnowledgeCutoffIso("not-a-date")).toThrow( + "invalid knowledge cutoff", + ); +}); + afterEach(() => { vi.unstubAllGlobals(); window.history.replaceState({}, "", "/"); @@ -1971,6 +1982,35 @@ describe("App, authenticated", () => { expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("converts the local knowledge cutoff to UTC for Global Ask", async () => { + const fetchMock = stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + expect(screen.getByLabelText("Use evidence available by (optional)")).toBeInTheDocument(); + expect(screen.getByText("Choose a time on this device, or leave blank to use the latest evidence.")).toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Phoenix?"); + await userEvent.type( + screen.getByLabelText("Use evidence available by (optional)"), + "2026-01-15T12:00", + ); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + expect( + await screen.findByText("The cited project is supported by the stored semantic evidence."), + ).toBeInTheDocument(); + const askCall = fetchMock.mock.calls.find( + ([input, init]) => String(input).endsWith("/api/ask") && (init as RequestInit | undefined)?.method === "POST", + ); + expect(askCall).toBeTruthy(); + const askInit = askCall?.[1] as RequestInit | undefined; + expect(askInit).toBeDefined(); + expect(JSON.parse(String(askInit?.body))).toEqual({ + question: "Phoenix?", + verify_external: false, + knowledge_cutoff: new Date("2026-01-15T12:00").toISOString(), + }); + }); + it("localizes Ask delivery copy instead of rendering Korean literals in English", async () => { stubBackend({ askDelivery: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d78acd57..f8adb6881 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { useAuth } from "react-oidc-context"; import { askPostChat, askAgent, + optionalKnowledgeCutoffIso, BackendError, createAnalysisRun, startAnalysisRun, @@ -4830,11 +4831,11 @@ export function AskAgentPanel({ onOpenPost: (postId: string) => void; }) { const [question, setQuestion] = useState(""); + const [knowledgeCutoff, setKnowledgeCutoff] = useState(""); const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); const [verifyExternal, setVerifyExternal] = useState(false); - const [knowledgeCutoff, setKnowledgeCutoff] = useState(""); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); const now = new Date(); const localKnowledgeCutoffMax = new Date( @@ -4844,6 +4845,14 @@ export function AskAgentPanel({ async function handleAsk() { const normalized = question.trim(); if (!normalized) return; + let cutoff: string | undefined; + try { + cutoff = optionalKnowledgeCutoffIso(knowledgeCutoff); + } catch { + setAnswer(null); + setError(t("Enter a valid knowledge cutoff, then ask again.")); + return; + } setAsking(true); setError(null); try { @@ -4852,7 +4861,7 @@ export function AskAgentPanel({ accessToken, normalized, verifyExternal, - knowledgeCutoff ? new Date(knowledgeCutoff).toISOString() : undefined, + cutoff, ), ); } catch (err) { @@ -4888,13 +4897,15 @@ export function AskAgentPanel({ {t("Check eligible public claims")} + {post.historical_body_unavailable ? ( +

{t("Historical body unavailable")}

+ ) : null}