diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffc4cd3ba..18630b4b3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -237,9 +237,11 @@ same related-node walk), and reason-and-cite step over `gather_chat_sources`' retrieve step -- both Event-Lineage link kinds feed the chat's context, ABAC-rechecked per candidate post). Seeded fixture answers live in `post_chat_result` so -Ask is useful without a live LLM -- each fixture stores both "What -happened between these events?" and "Who is involved?" (Keymen, or -an explicit no-Keyman sentence). A missing orchestrator and no stored +Ask is useful without a live LLM -- each fixture stores "What +happened between these events?", "Who is involved?" (Keymen, or +an explicit no-Keyman sentence), and "What is the next commitment?" +(the seeded Calendar ticket title plus due date, or an explicit +no-commitment sentence on rec-006). A missing orchestrator and no stored match is 503; the popup shows `Chat unavailable (LLM orchestrator not configured)` rather than a raw HTTP status. After that 503 the free-text Ask box is hidden and diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c068eae3..f41f5f714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.63.0] - 2026-08-14 + +### Added + +- A third seeded Ask question, "What is the next commitment?", on + every fixture that already answers the first two chips. A-100 + names Send Northridge Grid the revised quote due 2026-01-12; + B-200 names the Westfield specification due 2026-01-14; Riverbend + names its calendar ticket due 2026-01-09; rec-006 says it has no + open commitment. After `make seed` the popup chips connect Ask + to the same dated tickets home Calendar already lists. + ## [0.62.0] - 2026-08-14 ### Added diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index e04385659..2f9fe77ff 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -26,6 +26,7 @@ ) from lineageweave.post_chat import ( CANONICAL_CHAT_QUESTION, + CANONICAL_COMMITMENT_QUESTION, CANONICAL_INVOLVED_QUESTION, ChatSourceDocument, normalize_chat_question, @@ -276,11 +277,23 @@ def seeded_demo_involved_chat() -> SeededChat: ) +def seeded_demo_commitment_chat() -> SeededChat: + """Synthetic Calendar answer for the demo public post -- not an LLM result.""" + return SeededChat( + answer_text=( + "The next commitment is Send Northridge Grid the revised quote, " + "due 2026-01-12." + ), + cited_titles=("Demo public post",), + ) + + def seeded_demo_exchanges() -> list[tuple[str, SeededChat]]: - """Both canned Ask Q&As `make seed` writes for the demo public post.""" + """Canned Ask Q&As `make seed` writes for the demo public post.""" return [ (CANONICAL_CHAT_QUESTION, seeded_demo_chat()), (CANONICAL_INVOLVED_QUESTION, seeded_demo_involved_chat()), + (CANONICAL_COMMITMENT_QUESTION, seeded_demo_commitment_chat()), ] @@ -302,12 +315,22 @@ def seeded_fixture_involved_chat(post_title: str) -> SeededChat | None: return _INVOLVED_CHATS.get(post_title) +def seeded_fixture_commitment_chat(post_title: str) -> SeededChat | None: + """Synthetic Calendar answer for a reconstruct/calendar fixture title. + + Not an LLM result. Returns None when the title has no seeded + commitment answer so an unknown question still 503s instead of + inventing a ticket. + """ + return _COMMITMENT_CHATS.get(post_title) + + def seeded_fixture_exchanges(post_title: str) -> list[tuple[str, SeededChat]]: """Every canned Ask Q&A `make seed` writes for ``post_title``. - Order is what-happened first, then who-is-involved, so GET history - and the popup chips stay stable. Empty when the title is not a - known seed fixture. + Order is what-happened, who-is-involved, then next-commitment, so + GET history and the popup chips stay stable. Empty when the title + is not a known seed fixture. """ exchanges: list[tuple[str, SeededChat]] = [] happened = seeded_fixture_chat(post_title) @@ -316,6 +339,9 @@ def seeded_fixture_exchanges(post_title: str) -> list[tuple[str, SeededChat]]: involved = seeded_fixture_involved_chat(post_title) if involved is not None: exchanges.append((CANONICAL_INVOLVED_QUESTION, involved)) + commitment = seeded_fixture_commitment_chat(post_title) + if commitment is not None: + exchanges.append((CANONICAL_COMMITMENT_QUESTION, commitment)) return exchanges @@ -452,3 +478,55 @@ def _chat(answer: str, *cited: str) -> SeededChat: "Follow-up on the Riverbend order confirmation", ), } + +_COMMITMENT_CHATS: dict[str, SeededChat] = { + "Initial site visit and project scope discussion": _chat( + "After the A-100 site visit the next commitment is Send Northridge " + "Grid the revised quote, due 2026-01-12.", + "Initial site visit and project scope discussion", + ), + "Pricing renegotiation follow-up": _chat( + "The next commitment is Send Northridge Grid the revised quote, " + "due 2026-01-12.", + "Pricing renegotiation follow-up", + ), + "Pricing renegotiation: revised quote sent": _chat( + "The revised quote is already sent; the next commitment is still " + "Send Northridge Grid the revised quote, due 2026-01-12.", + "Pricing renegotiation: revised quote sent", + ), + "Delivery schedule question raised": _chat( + "The next commitment on this delivery-schedule branch is Confirm " + "the delivery window with logistics, due 2026-01-16.", + "Delivery schedule question raised", + ), + "Delivery schedule confirmed with logistics": _chat( + "The delivery window is confirmed; the next open commitment on the " + "A-100 thread is Send Northridge Grid the revised quote, due 2026-01-12.", + "Delivery schedule confirmed with logistics", + ), + "Unrelated: annual account review": _chat( + "This annual account review does not have an open commitment.", + "Unrelated: annual account review", + ), + "Technical specification review meeting": _chat( + "The next commitment after the B-200 review meeting is Send " + "Westfield Power the revised specification, due 2026-01-14.", + "Technical specification review meeting", + ), + "Specification revision requested": _chat( + "The next commitment is Send Westfield Power the revised " + "specification, due 2026-01-14.", + "Specification revision requested", + ), + "Revised specification approved": _chat( + "The specification is approved; the next commitment is still Send " + "Westfield Power the revised specification, due 2026-01-14.", + "Revised specification approved", + ), + "Follow-up on the Riverbend order confirmation": _chat( + "The next commitment is Send Riverbend the revised delivery " + "schedule, due 2026-01-09.", + "Follow-up on the Riverbend order confirmation", + ), +} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 706c36a0f..4809349e5 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -558,10 +558,13 @@ def test_seed_demo_chat_surfaces_on_get_and_post_chat(client, demo_analyst_token assert questions == [ "What happened between these events?", "Who is involved?", + "What is the next commitment?", ] assert "Northridge Grid" in history.json()["exchanges"][0]["answer_text"] assert "Ada West" in history.json()["exchanges"][1]["answer_text"] assert "Priya Nair" in history.json()["exchanges"][1]["answer_text"] + assert "Send Northridge Grid the revised quote" in history.json()["exchanges"][2]["answer_text"] + assert "2026-01-12" in history.json()["exchanges"][2]["answer_text"] asked = client.post( f"/api/posts/{seeded_db['public_post_id']}/chat", @@ -580,6 +583,15 @@ def test_seed_demo_chat_surfaces_on_get_and_post_chat(client, demo_analyst_token assert "Ada West" in involved.json()["answer_text"] assert "Priya Nair" in involved.json()["answer_text"] + commitment = client.post( + f"/api/posts/{seeded_db['public_post_id']}/chat", + json={"question": "What's the next commitment?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert commitment.status_code == 200, commitment.text + assert "Send Northridge Grid the revised quote" in commitment.json()["answer_text"] + assert "2026-01-12" in commitment.json()["answer_text"] + def test_seed_fixture_chats_surface_on_post_chat(client, demo_analyst_token, seeded_db) -> None: """The A-100 fork and calendar commitment `make seed` writes must @@ -656,8 +668,18 @@ def test_seed_fixture_chats_surface_on_post_chat(client, demo_analyst_token, see assert [row["question_text"] for row in fork_history.json()["exchanges"]] == [ "What happened between these events?", "Who is involved?", + "What is the next commitment?", ] + fork_commitment = client.post( + f"/api/posts/{fork_id}/chat", + json={"question": "What is the next commitment?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert fork_commitment.status_code == 200, fork_commitment.text + assert "Send Northridge Grid the revised quote" in fork_commitment.json()["answer_text"] + assert "2026-01-12" in fork_commitment.json()["answer_text"] + calendar = client.post( f"/api/posts/{calendar_id}/chat", json={"question": "What happened?"}, @@ -674,6 +696,15 @@ def test_seed_fixture_chats_surface_on_post_chat(client, demo_analyst_token, see assert calendar_involved.status_code == 200, calendar_involved.text assert "does not name a Keyman" in calendar_involved.json()["answer_text"] + calendar_commitment = client.post( + f"/api/posts/{calendar_id}/chat", + json={"question": "What is the next commitment?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert calendar_commitment.status_code == 200, calendar_commitment.text + assert "Send Riverbend the revised delivery schedule" in calendar_commitment.json()["answer_text"] + assert "2026-01-09" in calendar_commitment.json()["answer_text"] + missing = client.post( f"/api/posts/{seeded_db['own_private_post_id']}/chat", json={"question": "What happened between these events?"}, diff --git a/frontend/package.json b/frontend/package.json index 2693a995d..51171fa4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.62.0", + "version": "0.63.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 45a3fb13e..8a349e236 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -671,6 +671,13 @@ describe("App, authenticated", () => { cited_post_ids: ["post-1"], cited_posts: [{ post_id: "post-1", post_title: "Public post" }], }, + { + question_text: "What is the next commitment?", + answer_text: + "The next commitment is Send Northridge Grid the revised quote, due 2026-01-12.", + cited_post_ids: ["post-1"], + cited_posts: [{ post_id: "post-1", post_title: "Public post" }], + }, ], }), ); @@ -784,8 +791,12 @@ describe("App, authenticated", () => { expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(), ); expect(screen.getByText("Ada West and Priya Nair are the Keymen on this thread.")).toBeInTheDocument(); + expect( + screen.getByText("The next commitment is Send Northridge Grid the revised quote, due 2026-01-12."), + ).toBeInTheDocument(); expect(screen.getByRole("button", { name: /ask seeded question: what happened between these events/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /ask seeded question: who is involved/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /ask seeded question: what is the next commitment/i })).toBeInTheDocument(); }); it("asks a chat question and slides in the evidence panel for a cited source on click", async () => { @@ -831,9 +842,12 @@ describe("App, authenticated", () => { expect( screen.getByText("Only seeded questions can be answered without an orchestrator."), ).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: /ask seeded question/i })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: /ask seeded question/i })).toHaveLength(3); expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(); expect(screen.getByText("Ada West and Priya Nair are the Keymen on this thread.")).toBeInTheDocument(); + expect( + screen.getByText("The next commitment is Send Northridge Grid the revised quote, due 2026-01-12."), + ).toBeInTheDocument(); }); it("shows a clear empty state when evaluate is 503 without an orchestrator", async () => { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f3293283..3bbdd6556 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.62.0" +__version__ = "0.63.0" diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 4e359ba96..7ec67e943 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -28,10 +28,12 @@ CANONICAL_CHAT_QUESTION = "What happened between these events?" CANONICAL_INVOLVED_QUESTION = "Who is involved?" +CANONICAL_COMMITMENT_QUESTION = "What is the next commitment?" _TRAILING_PUNCT = re.compile(r"[?.!\s]+$") _CANONICAL_QUESTION_NORM = "what happened between these events" _INVOLVED_QUESTION_NORM = "who is involved" +_COMMITMENT_QUESTION_NORM = "what is the next commitment" def normalize_chat_question(question: str) -> str: @@ -40,13 +42,16 @@ def normalize_chat_question(question: str) -> str: Seeded Ask matches this form, never a live paraphrase. ``What happened?`` is an alias of the popup placeholder so a short type-in still hits the stored fixture answer. ``Who's involved?`` aliases - the second seeded chip that names Keymen. + the second seeded chip that names Keymen. ``What's the next + commitment?`` aliases the third chip that names the Calendar ticket. """ folded = _TRAILING_PUNCT.sub("", " ".join(question.strip().lower().split())) if folded == "what happened": return _CANONICAL_QUESTION_NORM if folded in {"who's involved", "who is involved here"}: return _INVOLVED_QUESTION_NORM + if folded in {"what's the next commitment", "what is the next commitment here"}: + return _COMMITMENT_QUESTION_NORM return folded diff --git a/pyproject.toml b/pyproject.toml index a7ddcee86..450125071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.62.0" +version = "0.63.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index a0ec8370e..03049bc0a 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -449,7 +449,7 @@ def _seed_demo_public_chat(cur, post_id) -> None: """Write the popup Ask answers for the demo public post. Idempotent: re-seed replaces the same rows so GET/POST chat stay - non-empty without a live orchestrator. Writes both canned + non-empty without a live orchestrator. Writes the canned questions so the chips are not a single prompt. """ from backend.app.post_chat_ingestion import seeded_demo_exchanges @@ -464,7 +464,7 @@ def _seed_fixture_chats(cur) -> None: Event Lineage click-through stays an empty Ask box without this when the orchestrator is off. Idempotent -- finds existing titles so a re-seed after the lineage insert's early-return still fills - the popup. Writes both canned questions per fixture. + the popup. Writes the canned questions per fixture. """ from lineageweave.fixtures import ambiguous_commitment_post, sample_records from backend.app.post_chat_ingestion import seeded_fixture_exchanges diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index 99b84da7a..d3603d374 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -14,15 +14,18 @@ from backend.app.post_chat_ingestion import ( seeded_demo_chat, + seeded_demo_commitment_chat, seeded_demo_exchanges, seeded_demo_involved_chat, seeded_fixture_chat, + seeded_fixture_commitment_chat, seeded_fixture_exchanges, seeded_fixture_involved_chat, ) from lineageweave.fixtures import ambiguous_commitment_post, fixture_thread_cast, sample_records from lineageweave.post_chat import ( CANONICAL_CHAT_QUESTION, + CANONICAL_COMMITMENT_QUESTION, CANONICAL_INVOLVED_QUESTION, ChatSourceDocument, ContextualOrchestratorPostChatClient, @@ -48,12 +51,20 @@ def test_normalize_chat_question_aliases_who_is_involved() -> None: assert normalize_chat_question(" who is involved here? ") == involved +def test_normalize_chat_question_aliases_next_commitment() -> None: + commitment = normalize_chat_question(CANONICAL_COMMITMENT_QUESTION) + assert commitment == "what is the next commitment" + assert normalize_chat_question("What's the next commitment?") == commitment + assert normalize_chat_question(" what is the next commitment here? ") == commitment + + def test_every_sample_record_has_a_seeded_chat_answer() -> None: """Event Lineage click-through must have a stored Ask answer for every reconstruct fixture -- not a shared placeholder, not live LLM. """ seen: set[str] = set() involved_seen: set[str] = set() + commitment_seen: set[str] = set() for rec in sample_records(): chat = seeded_fixture_chat(rec.label) assert chat is not None, rec.label @@ -74,7 +85,28 @@ def test_every_sample_record_has_a_seeded_chat_answer() -> None: else: assert "does not name a Keyman" in involved.answer_text questions = [question for question, _ in seeded_fixture_exchanges(rec.label)] - assert questions == [CANONICAL_CHAT_QUESTION, CANONICAL_INVOLVED_QUESTION] + assert questions == [ + CANONICAL_CHAT_QUESTION, + CANONICAL_INVOLVED_QUESTION, + CANONICAL_COMMITMENT_QUESTION, + ] + commitment = seeded_fixture_commitment_chat(rec.label) + assert commitment is not None, rec.label + assert commitment.answer_text.strip() + assert rec.label in commitment.cited_titles + assert commitment.answer_text not in commitment_seen + commitment_seen.add(commitment.answer_text) + if rec.label == "Unrelated: annual account review": + assert "does not have an open commitment" in commitment.answer_text + elif rec.secondary_key == "proj-beta": + assert "Send Westfield Power the revised specification" in commitment.answer_text + assert "2026-01-14" in commitment.answer_text + elif rec.label == "Delivery schedule question raised": + assert "Confirm the delivery window with logistics" in commitment.answer_text + assert "2026-01-16" in commitment.answer_text + else: + assert "Send Northridge Grid the revised quote" in commitment.answer_text + assert "2026-01-12" in commitment.answer_text calendar_title, _ = ambiguous_commitment_post() calendar = seeded_fixture_chat(calendar_title) assert calendar is not None @@ -82,17 +114,31 @@ def test_every_sample_record_has_a_seeded_chat_answer() -> None: calendar_involved = seeded_fixture_involved_chat(calendar_title) assert calendar_involved is not None assert "does not name a Keyman" in calendar_involved.answer_text + calendar_commitment = seeded_fixture_commitment_chat(calendar_title) + assert calendar_commitment is not None + assert "Send Riverbend the revised delivery schedule" in calendar_commitment.answer_text + assert "2026-01-09" in calendar_commitment.answer_text + assert [question for question, _ in seeded_fixture_exchanges(calendar_title)] == [ + CANONICAL_CHAT_QUESTION, + CANONICAL_INVOLVED_QUESTION, + CANONICAL_COMMITMENT_QUESTION, + ] assert seeded_fixture_chat("not a fixture title") is None assert seeded_fixture_involved_chat("not a fixture title") is None + assert seeded_fixture_commitment_chat("not a fixture title") is None assert seeded_fixture_exchanges("not a fixture title") == [] demo = seeded_demo_chat() assert "Northridge Grid" in demo.answer_text demo_involved = seeded_demo_involved_chat() assert "Ada West" in demo_involved.answer_text assert "Priya Nair" in demo_involved.answer_text + demo_commitment = seeded_demo_commitment_chat() + assert "Send Northridge Grid the revised quote" in demo_commitment.answer_text + assert "2026-01-12" in demo_commitment.answer_text assert [question for question, _ in seeded_demo_exchanges()] == [ CANONICAL_CHAT_QUESTION, CANONICAL_INVOLVED_QUESTION, + CANONICAL_COMMITMENT_QUESTION, ] diff --git a/uv.lock b/uv.lock index a1e422abe..2a96e56f8 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.62.0" +version = "0.63.0" source = { virtual = "." } dependencies = [ { name = "certifi" },