From 807712a3a220e31ce3c51e77439694522803ace9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:12:59 +0000 Subject: [PATCH 1/3] feat: show ThreadWeave conversations fail-closed (v0.81.0) After login, Conversations names threadweave_not_available when the port is down and lists accepted tree roots when ThreadWeave threads visible posts. A hidden parent is omitted. Never invent a parent. Independent exact-head APPROVE required. Do not mix into #74 or #92. --- AGENTS.md | 8 +- ARCHITECTURE.md | 6 + ...0-threadweave-conversations-fail-closed.md | 9 + CHANGELOG.md | 10 + backend/app/config.py | 5 + backend/app/conversation_ingestion.py | 34 +++ backend/app/main.py | 24 ++ backend/tests/test_config.py | 8 + .../adr/0021-threadweave-conversation-port.md | 55 ++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 67 ++++ frontend/src/App.tsx | 82 +++++ frontend/src/api.ts | 17 ++ lineageweave/__init__.py | 2 +- lineageweave/threadweave_client.py | 272 +++++++++++++++++ pyproject.toml | 2 +- tests/test_threadweave_client.py | 285 ++++++++++++++++++ uv.lock | 2 +- 18 files changed, 884 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/0.81.0-threadweave-conversations-fail-closed.md create mode 100644 backend/app/conversation_ingestion.py create mode 100644 docs/adr/0021-threadweave-conversation-port.md create mode 100644 lineageweave/threadweave_client.py create mode 100644 tests/test_threadweave_client.py diff --git a/AGENTS.md b/AGENTS.md index c790995c..a071aa49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,9 @@ This repo depends on real ContextualWisdomLab-org packages rather than reimplementing them: - [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) for - tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls). + tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls) + and the buyer-facing Conversations port (`threadweave_client.py`) -- + never invent a parent. - [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for multi-channel score fusion (`weighted_convex_fuse` in `reconstruct.py`) and the buyer-facing Rankings port @@ -53,7 +55,9 @@ does it (`gh repo list ContextualWisdomLab`). must set `available = False` and make their channel dropped + renormalized (`reconstruct.active_weights`), never silently return a placeholder score, invented Keyman, guessed relationship, fabricated -summary/chat, or invented commitment. A missing signal and a +summary/chat, or invented commitment. `ThreadWeaveClient` fails closed +with `ThreadWeaveNotAvailable` and an empty conversation list -- never +an invented parent. A missing signal and a confidently-negative signal are different things. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb..1425f3ea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,6 +67,7 @@ flowchart LR | `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | +| `threadweave_client.py` | Fail-closed ThreadWeave conversation port (`thread_messages` in-process; never invent a parent) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | | `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres | @@ -123,6 +124,11 @@ flowchart LR `RankWeaveNotAvailable`. `GET /api/rankings` then returns `rankweave_not_available` and an empty ranking list. Hidden posts are omitted from every channel. See ADR 0024. +- **ThreadWeave is an in-process library, not an HTTP host.** + `threadweave_client.py`'s default transport raises + `ThreadWeaveNotAvailable`. `GET /api/conversations` then returns + `threadweave_not_available` and an empty conversation list. Hidden + parents are omitted so the child becomes a root. See ADR 0021. ## Standards and citations diff --git a/CHANGELOG.d/0.81.0-threadweave-conversations-fail-closed.md b/CHANGELOG.d/0.81.0-threadweave-conversations-fail-closed.md new file mode 100644 index 00000000..51825108 --- /dev/null +++ b/CHANGELOG.d/0.81.0-threadweave-conversations-fail-closed.md @@ -0,0 +1,9 @@ +# 0.81.0 — Fail-closed ThreadWeave conversations + +## Added + +- Home Conversations panel threads visible posts through + `ThreadWeaveClient`. After login with the port disabled or the + library missing, Demo Analyst sees **Conversations · ThreadWeave not + available**. An accepted tree lists the root title; click opens that + post. A hidden parent is omitted. Never invent a parent. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28..652cca34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.81.0] - 2026-08-17 + +### Added + +- Home Conversations panel threads visible posts through ThreadWeave. + After login with the port disabled or the library missing, Demo + Analyst sees **Conversations · ThreadWeave not available**. An + accepted tree lists the root title; click opens that post. A hidden + parent is omitted. Never invent a parent. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/backend/app/config.py b/backend/app/config.py index 68cad343..37cba9e6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -52,6 +52,10 @@ class Settings: # RankWeaveNotAvailable -- never invent a fused score. Default false # uses the in-process library already required by reconstruct.py. rankweave_disabled: bool + # ThreadWeave conversation port (ADR 0021). True = fail-closed + # ThreadWeaveNotAvailable -- never invent a parent. Default false + # uses the in-process library already required by reconstruct.py. + threadweave_disabled: bool @property def keycloak_jwks_uri(self) -> str: @@ -85,6 +89,7 @@ def load_settings() -> Settings: valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "") + threadweave_disabled=os.environ.get("THREADWEAVE_DISABLED", "") .strip() .lower() in {"1", "true", "yes", "on"}, diff --git a/backend/app/conversation_ingestion.py b/backend/app/conversation_ingestion.py new file mode 100644 index 00000000..a40306d0 --- /dev/null +++ b/backend/app/conversation_ingestion.py @@ -0,0 +1,34 @@ +"""Load visible posts + visible-only lineage edges as ThreadWeave messages. + +A hidden parent is omitted from ``references`` so the visible child +becomes a JWZ root. This module never invents a parent id. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Mapping + +from lineageweave.threadweave_client import conversation_messages_from_rows + +if TYPE_CHECKING: + import asyncpg + +__all__ = [ + "conversation_messages_from_rows", + "load_visible_conversation_messages", +] + + +async def load_visible_conversation_messages( + conn: "asyncpg.Connection", + can_see_post: Callable[[Mapping[str, Any]], bool], +) -> list[dict[str, Any]]: + """Read ``source_post`` / ``post_lineage_edge`` and drop hidden parents.""" + posts = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post" + ) + edges = await conn.fetch( + "select parent_post_id, child_post_id from post_lineage_edge" + ) + return conversation_messages_from_rows(posts, edges, can_see_post) diff --git a/backend/app/main.py b/backend/app/main.py index 27f67911..30473213 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -55,6 +55,7 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient from lineageweave.rankweave_client import build_rankweave_client +from lineageweave.threadweave_client import build_threadweave_client from backend.app.activity_stream import ( create_valkey_client, @@ -67,6 +68,7 @@ from backend.app.affiliate_tree_ingestion import fetch_affiliate_forest, fetch_voc_evidence from backend.app.auth import CurrentAccount, get_current_account from backend.app.config import load_settings +from backend.app.conversation_ingestion import load_visible_conversation_messages from backend.app.db import create_pool, get_pool from backend.app.entity_relationship_ingestion import ( fetch_post_counterparties, @@ -242,6 +244,11 @@ def _rankweave_client(): return build_rankweave_client(disabled=load_settings().rankweave_disabled) +def _threadweave_client(): + """In-process ThreadWeave when enabled; otherwise fail-closed.""" + return build_threadweave_client(disabled=load_settings().threadweave_disabled) + + def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: """ABAC: public rows are visible; private rows require same-corp affiliation.""" if post["visibility_code"] == "public": @@ -1150,3 +1157,20 @@ async def read_rankings( return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True ) +@app.get("/api/conversations") +async def read_conversations( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """ThreadWeave forest of ABAC-visible posts (ADR 0021). + + Hidden parents are omitted so the child becomes a root. Never + invents a parent. Fail-closed when ThreadWeave is disabled or + the library is missing. + """ + _require_post_read(account) + async with pool.acquire() as conn: + messages = await load_visible_conversation_messages( + conn, lambda row: _can_see_post(account, row) + ) + return _threadweave_client().as_api_payload(messages) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c2f3994d..89065ad4 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -31,3 +31,11 @@ def test_rankweave_disabled_defaults_off(monkeypatch) -> None: def test_rankweave_disabled_flag_is_opt_in(monkeypatch) -> None: monkeypatch.setenv("RANKWEAVE_DISABLED", "1") assert load_settings().rankweave_disabled is True +def test_threadweave_disabled_defaults_off(monkeypatch) -> None: + monkeypatch.delenv("THREADWEAVE_DISABLED", raising=False) + assert load_settings().threadweave_disabled is False + + +def test_threadweave_disabled_flag_is_opt_in(monkeypatch) -> None: + monkeypatch.setenv("THREADWEAVE_DISABLED", "1") + assert load_settings().threadweave_disabled is True diff --git a/docs/adr/0021-threadweave-conversation-port.md b/docs/adr/0021-threadweave-conversation-port.md new file mode 100644 index 00000000..201b98f9 --- /dev/null +++ b/docs/adr/0021-threadweave-conversation-port.md @@ -0,0 +1,55 @@ +# ADR 0021 — Fail-closed ThreadWeave conversation port + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +LineageWeave already calls ThreadWeave inside `reconstruct.py` to +assemble RankWeave parent choices into trees. Demo Analyst had no +buyer-facing Conversations surface over those persisted edges. +ThreadWeave is an in-process library +([API contract](https://github.com/ContextualWisdomLab/ThreadWeave/blob/main/docs/API_CONTRACT.md)): +it does not define HTTP, a mailbox host, or authentication. A missing +package, a disabled port, or a JWZ dummy for a hidden parent must not +become an invented conversation. + +This ADR does not replace `reconstruct.py`, does not read naruon +tables, and does not bind the demo IdP to production Keyverse. ADR +0020 already reserved later mapping of mailbox threads onto lineage +posts; this slice threads *visible* `source_post` rows only. + +## Decision + +1. Consume ThreadWeave only through `ThreadWeaveClient`. The default + transport raises `ThreadWeaveNotAvailable`. `build_threadweave_client + (disabled=False)` uses `LibraryThreadWeaveTransport`, which imports + `thread_messages` inside the call so a missing package fail-closes. +2. `GET /api/conversations` (`post_read`) loads ABAC-visible posts and + visible-only `post_lineage_edge` rows as JWZ `references`. A hidden + parent is omitted; the child becomes a root. Never invent a parent. +3. Dummy JWZ containers (referenced-but-missing ids) lift their + children instead of projecting an untitled parent. +4. After login, Conversations sits above Calendar. Unavailable copy is + **Conversations · ThreadWeave not available**. An accepted tree + lists the root title; click opens that `source_post`. + +## Consequences + +`THREADWEAVE_DISABLED=1` keeps the fail-closed transport. The default +seeded stack uses the in-process library and lists the designed A-100 +fork when those posts are visible. Mailbox stays on ADR 0020 / #217. +Leftover pairs stay on #211. TEPP stays on #214. + +## References + +Crispin, M., & Murchison, K. (2008). *Internet Message Access Protocol +— SORT and THREAD extensions* (RFC 5256). IETF. +https://doi.org/10.17487/RFC5256 + +Zawinski, J. (2002). *Message threading* [Technical note]. +https://www.jwz.org/doc/threading.html + +Contextual Wisdom Lab. (2026). *ThreadWeave public API and version +contract* [Software documentation]. +https://github.com/ContextualWisdomLab/ThreadWeave/blob/main/docs/API_CONTRACT.md diff --git a/frontend/package.json b/frontend/package.json index 575b7c58..aacb6f74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.81.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a2640..e43b14dc 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -65,6 +65,15 @@ describe("App, authenticated", () => { fused_rank: number; }[]; }; + conversations?: { + status: "accepted" | "unavailable"; + status_reason: string | null; + conversations: { + post_id: string; + post_title: string; + children?: { post_id: string; post_title: string }[]; + }[]; + }; chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; @@ -178,6 +187,22 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/conversations")) { + const conversations = options?.conversations ?? { + port: "threadweave", + status: "unavailable" as const, + status_reason: "threadweave_not_available", + conversations: [], + }; + return Promise.resolve( + jsonResponse({ + port: "threadweave", + status: conversations.status, + status_reason: conversations.status_reason, + conversations: conversations.conversations, + }), + ); + } if (url.endsWith("/api/calendar")) { return Promise.resolve( jsonResponse({ @@ -1307,6 +1332,48 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("names ThreadWeave unavailability on home conversations instead of inventing a parent", async () => { + stubBackend(); + render(); + + expect(await screen.findByText("Conversations · ThreadWeave not available")).toBeInTheDocument(); + expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); + }); + + it("opens an accepted conversation tree root without inventing a parent", async () => { + stubBackend({ + conversations: { + status: "accepted", + status_reason: null, + conversations: [ + { + post_id: "post-1", + post_title: "Public post", + children: [ + { + post_id: "post-2", + post_title: "Pricing renegotiation: revised quote sent", + }, + ], + }, + ], + }, + }); + render(); + + const conversationButton = await screen.findByRole("button", { + name: /open conversation: public post/i, + }); + expect(conversationButton).toHaveTextContent("Public post"); + expect(conversationButton).toHaveTextContent("Conversations · threadweave"); + expect(conversationButton).toHaveTextContent("1 reply"); + expect(screen.queryByRole("button", { name: /open conversation: private parent/i })).not.toBeInTheDocument(); + + await userEvent.click(conversationButton); + + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + }); + it("shows upcoming commitments on the home page calendar and opens the post on click", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb..e01f36c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { evaluatePost, extractPostKeymen, fetchCalendar, + fetchConversations, fetchLineageGraph, fetchMe, fetchPost, @@ -37,6 +38,8 @@ import { type CalendarEntry, type ChatAnswer, type ChatExchange, + type ConversationForest, + type ConversationNode, type Counterparty, type EvaluationResponse, type IssueTicket, @@ -1371,6 +1374,83 @@ function RankingsPanel({ ); } +function ConversationsPanel({ + accessToken, + onSelectPost, +}: { + accessToken: string; + onSelectPost: (postId: string) => void; +}) { + const [forest, setForest] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + fetchConversations(accessToken) + .then(setForest) + .catch((err) => setError(String(err))); + }, [accessToken]); + + return ( +
+
+

Conversations

+ {forest && ( + + {forest.status === "accepted" + ? "threadweave" + : `threadweave · ${forest.status_reason ?? "unavailable"}`} + + )} +
+ {error &&

{error}

} + {forest === null && !error &&

Loading conversations...

} + {forest && forest.status === "unavailable" && ( +

Conversations · ThreadWeave not available

+ )} + {forest && forest.status === "accepted" && forest.conversations.length === 0 && ( +

No conversation trees from ThreadWeave.

+ )} + {forest && forest.conversations.length > 0 && ( +
    + {forest.conversations.map((tree) => ( + + ))} +
+ )} +
+ ); +} + +function ConversationTreeItem({ + tree, + onSelectPost, +}: { + tree: ConversationNode; + onSelectPost: (postId: string) => void; +}) { + const replyCount = tree.children?.length ?? 0; + return ( +
  • + +
  • + ); +} + function CalendarPanel({ accessToken, onSelectPost, @@ -1690,6 +1770,8 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> + +
    diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad..6bc26372 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -515,3 +515,20 @@ export interface RankingList { export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } + +export interface ConversationNode { + post_id: string; + post_title: string; + children?: ConversationNode[]; +} + +export interface ConversationForest { + port: string; + status: "accepted" | "unavailable"; + status_reason: string | null; + conversations: ConversationNode[]; +} + +export function fetchConversations(accessToken: string): Promise { + return backendFetch("/api/conversations", accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009..c3469eba 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.81.0" diff --git a/lineageweave/threadweave_client.py b/lineageweave/threadweave_client.py new file mode 100644 index 00000000..491ded50 --- /dev/null +++ b/lineageweave/threadweave_client.py @@ -0,0 +1,272 @@ +"""Fail-closed adapter for ThreadWeave's in-process JWZ/RFC 5256 threader. + +`ThreadWeave `_ is a +library, not an HTTP service (see its ``docs/API_CONTRACT.md``). This +client is the only LineageWeave port that may call ``thread_messages`` +for the buyer-facing Conversations surface. Reconstruction already uses +ThreadWeave inside ``reconstruct.py``; this module does not replace +that path and does not invent a parent. + +The default transport raises :class:`ThreadWeaveNotAvailable` so a +disabled or missing library is fail-closed, the same discipline as +:class:`lineageweave.tepp_client.TeppNotAvailable`. Wiring the +in-process library is additive (``LibraryThreadWeaveTransport``), not +a redesign. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Mapping + + +class ThreadWeaveNotAvailable(RuntimeError): + """Raised when the ThreadWeave conversation port is down or disabled.""" + + reason = "threadweave_not_available" + + +def conversation_messages_from_rows( + posts: list[Mapping[str, Any]], + edges: list[Mapping[str, Any]], + can_see_post: Callable[[Mapping[str, Any]], bool], +) -> list[dict[str, Any]]: + """Project ABAC-visible posts into ThreadWeave message dicts. + + Only edges whose parent *and* child are visible become JWZ + ``references``. A child whose parent is hidden has an empty + reference list and threads as a root. Never invent a parent. + """ + visible = [row for row in posts if can_see_post(row)] + visible_ids = {str(row["post_id"]) for row in visible} + parents_of: dict[str, list[str]] = {} + for edge in edges: + parent_id = str(edge["parent_post_id"]) + child_id = str(edge["child_post_id"]) + if parent_id in visible_ids and child_id in visible_ids: + parents_of.setdefault(child_id, []).append(parent_id) + messages: list[dict[str, Any]] = [] + for row in visible: + title = str(row.get("post_title") or "").strip() + if not title: + continue + post_id = str(row["post_id"]) + messages.append( + { + "message_id": post_id, + "post_title": title, + "references": tuple(parents_of.get(post_id, ())), + } + ) + return messages + + +def _no_transport(_messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + raise ThreadWeaveNotAvailable( + "threadweave_not_available: ThreadWeave conversation port is not " + "configured. Pass THREADWEAVE_DISABLED=0 (default) or a transport= " + "callable. Never invent a parent." + ) + + +def _import_threadweave() -> Any: + """Import ThreadWeave at call time so a missing package fail-closes.""" + import threadweave as tw + + return tw + + +@dataclass(frozen=True) +class ConversationNode: + """One visible post in a ThreadWeave tree. Never a fabricated parent.""" + + post_id: str + post_title: str + children: tuple["ConversationNode", ...] = () + + def to_json(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "post_id": self.post_id, + "post_title": self.post_title, + } + if self.children: + payload["children"] = [child.to_json() for child in self.children] + return payload + + +@dataclass(frozen=True) +class ConversationForest: + """Accepted conversation projection. Empty when ThreadWeave returned no trees.""" + + trees: tuple[ConversationNode, ...] + + def to_json(self) -> list[dict[str, Any]]: + return [tree.to_json() for tree in self.trees] + + +def _title_from_payload(payload: object) -> str: + if isinstance(payload, dict): + return str(payload.get("post_title") or "").strip() + return "" + + +def _nodes_from_container(container: object) -> list[ConversationNode]: + """Project a ThreadWeave container. Dummy or untitled nodes lift children. + + JWZ creates dummy containers for referenced-but-missing ids. A hidden + parent must never become a buyer-visible node: omit it and let the + visible child become a root. + """ + child_nodes: list[ConversationNode] = [] + for child in getattr(container, "children", ()) or (): + child_nodes.extend(_nodes_from_container(child)) + message = getattr(container, "message", None) + if message is None: + return child_nodes + post_id = str(getattr(message, "message_id", "") or "").strip() + title = _title_from_payload(getattr(message, "payload", None)) + if not post_id or not title: + return child_nodes + return [ + ConversationNode( + post_id=post_id, + post_title=title, + children=tuple(child_nodes), + ) + ] + + +def _nodes_from_mapping(item: MappingLike) -> list[ConversationNode]: + raw_children = item.get("children") or [] + child_nodes: list[ConversationNode] = [] + if isinstance(raw_children, list): + for child in raw_children: + child_nodes.extend(_project_tree(child)) + post_id = str(item.get("post_id") or item.get("message_id") or "").strip() + title = str(item.get("post_title") or "").strip() + if not post_id or not title: + return child_nodes + return [ + ConversationNode( + post_id=post_id, + post_title=title, + children=tuple(child_nodes), + ) + ] + + +# Typed as a protocol-shaped mapping without importing Protocol just for this. +MappingLike = dict[str, Any] + + +def _project_tree(item: object) -> list[ConversationNode]: + if item is None: + return [] + if isinstance(item, dict): + return _nodes_from_mapping(item) + if hasattr(item, "message") or hasattr(item, "children"): + return _nodes_from_container(item) + return [] + + +def project_conversation_forest(raw: object) -> ConversationForest: + """Accept transport output. Unknown shapes fail closed. Never invent a parent.""" + if not isinstance(raw, list): + raise ThreadWeaveNotAvailable( + "threadweave_not_available: conversation envelope is not a tree list" + ) + trees: list[ConversationNode] = [] + for item in raw: + trees.extend(_project_tree(item)) + return ConversationForest(trees=tuple(trees)) + + +class LibraryThreadWeaveTransport: + """Call ThreadWeave ``thread_messages`` in-process. Import is inside the call.""" + + def __call__(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + try: + tw = _import_threadweave() + except ImportError as exc: + raise ThreadWeaveNotAvailable( + "threadweave_not_available: threadweave package is not installed. " + "Never invent a parent." + ) from exc + tw_messages = [] + for row in messages: + message_id = str(row.get("message_id") or "").strip() + title = str(row.get("post_title") or "").strip() + if not message_id or not title: + continue + references = [ + str(ref).strip() + for ref in (row.get("references") or ()) + if str(ref).strip() + ] + tw_messages.append( + tw.Message( + message_id=message_id, + references=references, + payload={"post_title": title}, + ) + ) + try: + roots = tw.thread_messages(tw_messages) + except Exception as exc: + raise ThreadWeaveNotAvailable( + f"threadweave_not_available: thread_messages failed ({exc})" + ) from exc + forest = ConversationForest( + trees=tuple( + node + for root in roots + for node in _nodes_from_container(root) + ) + ) + return forest.to_json() + + +def build_threadweave_client(disabled: bool = False) -> "ThreadWeaveClient": + """``disabled=True`` keeps the default fail-closed transport.""" + if disabled: + return ThreadWeaveClient() + return ThreadWeaveClient(transport=LibraryThreadWeaveTransport()) + + +class ThreadWeaveClient: + """Threads visible posts through a pluggable ThreadWeave transport.""" + + def __init__( + self, + transport: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] = _no_transport, + ) -> None: + self._transport = transport + + def thread_conversations(self, messages: list[dict[str, Any]]) -> ConversationForest: + try: + raw = self._transport(messages) + except ThreadWeaveNotAvailable: + raise + except Exception as exc: + raise ThreadWeaveNotAvailable( + f"threadweave_not_available: conversation transport failed ({exc})" + ) from exc + return project_conversation_forest(raw) + + def as_api_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]: + """Buyer-visible conversation status. Never invents a parent.""" + try: + forest = self.thread_conversations(messages) + except ThreadWeaveNotAvailable: + return { + "port": "threadweave", + "status": "unavailable", + "status_reason": ThreadWeaveNotAvailable.reason, + "conversations": [], + } + return { + "port": "threadweave", + "status": "accepted", + "status_reason": None, + "conversations": forest.to_json(), + } diff --git a/pyproject.toml b/pyproject.toml index 764ebad7..95c10c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.81.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/tests/test_threadweave_client.py b/tests/test_threadweave_client.py new file mode 100644 index 00000000..c7646516 --- /dev/null +++ b/tests/test_threadweave_client.py @@ -0,0 +1,285 @@ +"""Fail-closed ThreadWeave conversation port. + +ThreadWeave is an in-process JWZ/RFC 5256 library. LineageWeave +threads only visible posts and visible-only lineage edges. A hidden +parent is omitted; the child becomes a root. The client never invents +a parent. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from lineageweave.threadweave_client import ( + LibraryThreadWeaveTransport, + ThreadWeaveClient, + ThreadWeaveNotAvailable, + build_threadweave_client, + conversation_messages_from_rows, + project_conversation_forest, +) + + +def test_default_transport_fails_closed() -> None: + client = ThreadWeaveClient() + with pytest.raises(ThreadWeaveNotAvailable, match="threadweave_not_available"): + client.thread_conversations( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + ) + + +def test_default_payload_never_invents_a_parent() -> None: + payload = ThreadWeaveClient().as_api_payload( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + ) + + assert payload == { + "port": "threadweave", + "status": "unavailable", + "status_reason": "threadweave_not_available", + "conversations": [], + } + + +def test_disabled_factory_fails_closed() -> None: + client = build_threadweave_client(disabled=True) + payload = client.as_api_payload( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + ) + assert payload["status"] == "unavailable" + assert payload["conversations"] == [] + + +def test_library_transport_fails_closed_when_threadweave_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def boom() -> object: + raise ImportError("No module named 'threadweave'") + + monkeypatch.setattr( + "lineageweave.threadweave_client._import_threadweave", boom + ) + client = ThreadWeaveClient(transport=LibraryThreadWeaveTransport()) + with pytest.raises(ThreadWeaveNotAvailable, match="threadweave_not_available"): + client.thread_conversations( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + ) + assert client.as_api_payload( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + )["conversations"] == [] + + +def test_library_transport_fails_closed_when_thread_messages_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeTw: + class Message: + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + @staticmethod + def thread_messages(_messages: object) -> list: + raise RuntimeError("cyclic references") + + monkeypatch.setattr( + "lineageweave.threadweave_client._import_threadweave", lambda: FakeTw + ) + client = ThreadWeaveClient(transport=LibraryThreadWeaveTransport()) + with pytest.raises(ThreadWeaveNotAvailable, match="threadweave_not_available"): + client.thread_conversations( + [{"message_id": "post-1", "post_title": "Public post", "references": []}] + ) + + +def test_injected_transport_returns_accepted_trees() -> None: + def fake_transport(_messages: list[dict]) -> list[dict]: + return [ + { + "post_id": "post-1", + "post_title": "Public post", + "children": [ + { + "post_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + } + ], + } + ] + + payload = ThreadWeaveClient(transport=fake_transport).as_api_payload( + [ + {"message_id": "post-1", "post_title": "Public post", "references": []}, + { + "message_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + "references": ["post-1"], + }, + ] + ) + + assert payload["status"] == "accepted" + assert payload["status_reason"] is None + assert payload["conversations"] == [ + { + "post_id": "post-1", + "post_title": "Public post", + "children": [ + { + "post_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + } + ], + } + ] + + +def test_library_transport_projects_monkeypatched_thread_messages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + class FakeMessage: + def __init__( + self, message_id: str, references: list[str], payload: dict[str, str] + ) -> None: + self.message_id = message_id + self.references = references + self.payload = payload + + class FakeTw: + Message = FakeMessage + + @staticmethod + def thread_messages(messages: list[FakeMessage]) -> list: + captured["ids"] = [message.message_id for message in messages] + captured["refs"] = [list(message.references) for message in messages] + root = SimpleNamespace( + message=messages[0], + children=[SimpleNamespace(message=messages[1], children=[])], + ) + return [root] + + monkeypatch.setattr( + "lineageweave.threadweave_client._import_threadweave", lambda: FakeTw + ) + payload = ThreadWeaveClient(transport=LibraryThreadWeaveTransport()).as_api_payload( + [ + {"message_id": "post-1", "post_title": "Public post", "references": []}, + { + "message_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + "references": ["post-1"], + }, + ] + ) + + assert captured["ids"] == ["post-1", "post-2"] + assert captured["refs"] == [[], ["post-1"]] + assert payload["conversations"][0]["post_title"] == "Public post" + assert payload["conversations"][0]["children"][0]["post_title"] == ( + "Pricing renegotiation: revised quote sent" + ) + + +def test_hidden_parent_is_omitted_and_child_becomes_root() -> None: + messages = conversation_messages_from_rows( + posts=[ + {"post_id": "hidden-parent", "post_title": "Private parent"}, + {"post_id": "post-2", "post_title": "Pricing renegotiation: revised quote sent"}, + ], + edges=[ + {"parent_post_id": "hidden-parent", "child_post_id": "post-2"}, + ], + can_see_post=lambda row: str(row["post_id"]) != "hidden-parent", + ) + + assert messages == [ + { + "message_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + "references": (), + } + ] + + def echo(rows: list[dict]) -> list[dict]: + return [ + { + "post_id": row["message_id"], + "post_title": row["post_title"], + "children": [], + } + for row in rows + ] + + payload = ThreadWeaveClient(transport=echo).as_api_payload(messages) + assert payload["conversations"] == [ + { + "post_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + } + ] + assert all( + "hidden-parent" not in str(tree) for tree in payload["conversations"] + ) + + +def test_visible_parent_is_the_only_jwz_reference() -> None: + messages = conversation_messages_from_rows( + posts=[ + {"post_id": "post-1", "post_title": "Public post"}, + { + "post_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + }, + ], + edges=[{"parent_post_id": "post-1", "child_post_id": "post-2"}], + can_see_post=lambda _row: True, + ) + by_id = {row["message_id"]: row for row in messages} + assert by_id["post-1"]["references"] == () + assert by_id["post-2"]["references"] == ("post-1",) + + +def test_unknown_envelope_fails_closed() -> None: + with pytest.raises(ThreadWeaveNotAvailable, match="threadweave_not_available"): + project_conversation_forest({"threads": [{"post_title": "spoofed"}]}) + + +def test_dummy_container_lifts_children_instead_of_inventing_a_parent() -> None: + dummy = SimpleNamespace( + message=None, + children=[ + SimpleNamespace( + message=SimpleNamespace( + message_id="post-2", + payload={"post_title": "Pricing renegotiation: revised quote sent"}, + ), + children=[], + ) + ], + ) + forest = project_conversation_forest([dummy]) + assert [tree.to_json() for tree in forest.trees] == [ + { + "post_id": "post-2", + "post_title": "Pricing renegotiation: revised quote sent", + } + ] + + +def test_blank_title_is_not_repaired_into_a_parent() -> None: + forest = project_conversation_forest( + [ + { + "post_id": "invented", + "post_title": "", + "children": [ + {"post_id": "post-2", "post_title": "Delivery schedule question raised"} + ], + } + ] + ) + assert [tree.post_id for tree in forest.trees] == ["post-2"] + assert all(tree.post_id != "invented" for tree in forest.trees) diff --git a/uv.lock b/uv.lock index 08eab776..ad084e88 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.81.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 465bf3325c7f3f2ca74b02aa9389afdddd075a04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:22:29 +0900 Subject: [PATCH 2/3] fix: repair config.py syntax and drop duplicate ConversationsPanel CI was failing two ways on this branch's own PR: 1. backend/app/config.py had a genuine SyntaxError: adding threadweave_disabled's `.strip().lower() in {...}` boolean conversion accidentally dropped the trailing comma after rankweave_disabled's own value AND reused rankweave_disabled's conversion chain for threadweave_disabled instead of giving it its own -- so rankweave_disabled (declared `bool`) was left assigned a bare string, and the file didn't even parse. Restored each field's own independent `.strip().lower() in {"1", "true", "yes", "on"}` conversion, matching the pattern this field already used before ThreadWeave support was added (see 8c020aa). 2. `` was rendered twice, back-to-back, with identical props -- a duplicate-line copy/paste. This made every ConversationsPanel query (`getByText`/`getByRole`, which require exactly one match) fail with "Found multiple elements", matching the two frontend test failures. Removed the duplicate line. Verified: backend/tests/test_config.py (6/6), full backend suite (304 passed, 16 skipped), frontend vitest (47/47), tsc build, and oxlint all green. Co-Authored-By: Claude Sonnet 5 --- backend/app/config.py | 3 +++ frontend/src/App.tsx | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 37cba9e6..a1fb8559 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -89,6 +89,9 @@ def load_settings() -> Settings: valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "") + .strip() + .lower() + in {"1", "true", "yes", "on"}, threadweave_disabled=os.environ.get("THREADWEAVE_DISABLED", "") .strip() .lower() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e01f36c9..18187213 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1771,7 +1771,6 @@ function PostList({ accessToken }: { accessToken: string }) { <> -
    From 031c97100871cf0a43728aaa9d6d46f8229e927f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:55:21 +0900 Subject: [PATCH 3/3] fix: ignore stale ConversationsPanel responses on accessToken change CodeRabbit review on this PR: ConversationsPanel's effect had no cleanup or in-flight guard. If accessToken changes (e.g. account switch) while a fetchConversations request for the OLD token is still in flight, and that request resolves after the effect re-runs with the new token, setForest(oldAccountsData) would overwrite the new state -- a previous account's ABAC-visible conversation titles leaking into the current session's view. Added the standard React guard: an `active` flag set false in the effect's cleanup, checked before either setForest or setError commits, plus resetting forest to null when accessToken changes so a stale tree never renders under a new identity even briefly. Verified: frontend vitest (47/47), tsc build, oxlint, backend suite (304 passed, 16 skipped) all still green. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18187213..693361a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1385,10 +1385,19 @@ function ConversationsPanel({ const [error, setError] = useState(null); useEffect(() => { + let active = true; + setForest(null); setError(null); fetchConversations(accessToken) - .then(setForest) - .catch((err) => setError(String(err))); + .then((result) => { + if (active) setForest(result); + }) + .catch((err) => { + if (active) setError(String(err)); + }); + return () => { + active = false; + }; }, [accessToken]); return (