diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..b10777aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project are documented here. Format follows ### Added +- Account-owned per-post Ask conversations can be listed, reopened, and + continued with current authorization reapplied to cited evidence (ADR 0228). - 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/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 7335f6fb9..c7d67de18 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -640,14 +640,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None: if run_kind_code == _TEPP_RUN_KIND: raise AnalysisRunCreateError( 422, - "Connect a TEPP transport from a Failed TEPP row; this endpoint " - "does not invent a measurement.", + "Ask an administrator to enable measurement, then retry from the failed measurement run.", ) if run_kind_code == _TOPIC_LINEAGE_RUN_KIND: raise AnalysisRunCreateError( 422, - "Connect a TEPP transport from a Failed topic-lineage row; this " - "endpoint does not invent a topic model.", + "Ask an administrator to enable topic-lineage analysis, then retry from the failed run.", ) if run_kind_code == _REPORT_RUN_KIND: raise AnalysisRunCreateError( diff --git a/backend/app/main.py b/backend/app/main.py index 08ff32428..6ea717364 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -130,6 +130,14 @@ gather_chat_sources, persist_post_chat, ) +from backend.app.post_ask_history import ( + PostAskConversationNotFound, + PostAskEvidenceChanged, + conversation_exists as post_ask_conversation_exists, + fetch_conversation as fetch_post_ask_conversation, + list_conversations as list_post_ask_conversations, + persist_turn as persist_post_ask_turn, +) from backend.app.post_content_queue import ( ensure_post_content_job, post_content_api_status, @@ -2954,6 +2962,7 @@ class ChatRequest(BaseModel): """JSON body for ``POST /api/posts/{post_id}/chat``.""" question: str + conversation_id: UUID | None = None class GlobalAskRequest(BaseModel): @@ -2962,6 +2971,41 @@ class GlobalAskRequest(BaseModel): question: str +async def _persist_post_ask_turn( + conn: asyncpg.Connection, + account: CurrentAccount, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: list[str], + cited_post_ids: list[str], +) -> UUID: + """Persist a completed turn after reauthorizing every citation.""" + try: + return await persist_post_ask_turn( + conn, + account.user_account_id, + post_id, + conversation_id, + question, + answer_text, + source_post_ids, + cited_post_ids, + can_see_post=lambda row: _can_see_post(account, row), + ) + except PostAskEvidenceChanged as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is temporarily unavailable because authorized evidence changed. Retry the question.", + ) from exc + except PostAskConversationNotFound as exc: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) from exc + + @app.get("/api/posts/{post_id}/chat") async def read_post_chat( post_id: str, @@ -3006,16 +3050,34 @@ async def chat_about_post( post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) async with pool.acquire() as conn: + if request.conversation_id is not None and not await post_ask_conversation_exists( + conn, account.user_account_id, post_id, request.conversation_id + ): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) stored = await fetch_persisted_chat(conn, post_id, question) if stored is not None: source_ids = [post_id] source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + stored["answer_text"], + source_ids, + list(stored["cited_post_ids"]), + ) return { "post_id": post_id, "answer_text": stored["answer_text"], "cited_post_ids": stored["cited_post_ids"], "cited_posts": stored["cited_posts"], "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } with use_llm_metadata(post_metadata): with traced( @@ -3067,8 +3129,19 @@ async def chat_about_post( "Saved evidence is still available.", ) from exc cited_ids = list(answer.cited_post_ids) + source_ids = [source.post_id for source in sources] async with pool.acquire() as conn: await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + answer.answer_text, + source_ids, + cited_ids, + ) await publish_activity_event( valkey, post_id, @@ -3081,10 +3154,67 @@ async def chat_about_post( "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_post_summaries(sources, cited_ids), - "source_post_ids": [source.post_id for source in sources], + "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } +@app.get("/api/posts/{post_id}/chat/conversations") +async def read_post_chat_conversations( + post_id: str, + limit: int = Query(50, ge=1, le=50), + before_updated_at: datetime | None = Query(None), + before_conversation_id: UUID | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """List this account's saved Ask conversations on one visible post.""" + await _load_visible_post(post_id, account, pool) + if (before_updated_at is None) != (before_conversation_id is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "before_updated_at and before_conversation_id must be provided together", + ) + async with pool.acquire() as conn: + return await list_post_ask_conversations( + conn, + account.user_account_id, + post_id, + limit=limit, + before_updated_at=before_updated_at, + before_conversation_id=before_conversation_id, + ) + + +@app.get("/api/posts/{post_id}/chat/conversations/{conversation_id}") +async def read_post_chat_conversation( + post_id: str, + conversation_id: UUID, + limit: int = Query(50, ge=1, le=50), + before_turn: int | None = Query(None, ge=1), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Load one owned transcript with currently authorized citations.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + conversation = await fetch_post_ask_conversation( + conn, + account.user_account_id, + post_id, + conversation_id, + lambda row: _can_see_post(account, row), + turn_limit=limit, + before_turn_ordinal=before_turn, + ) + if conversation is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) + return conversation + + @app.post("/api/ask", status_code=status.HTTP_202_ACCEPTED) async def ask_agent( request: GlobalAskRequest, diff --git a/backend/app/post_ask_history.py b/backend/app/post_ask_history.py new file mode 100644 index 000000000..116cffc87 --- /dev/null +++ b/backend/app/post_ask_history.py @@ -0,0 +1,409 @@ +"""Account-owned persistence for Ask conversations on one visible post. + +ADR 0228 reuses the ADR 0126 list/select/new contract with a required +post_id scope. Conversation ids are never Global Ask session ids. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg + +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + + +class PostAskConversationNotFound(LookupError): + """The requested conversation is absent, on another post, or another account.""" + + +class PostAskEvidenceChanged(RuntimeError): + """A cited post became unauthorized before the new turn could commit.""" + + +async def conversation_exists( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID, +) -> bool: + """Return whether this account owns the conversation on ``post_id``.""" + return bool( + await conn.fetchval( + """ + select exists( + select 1 + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + ) + """, + conversation_id, + user_account_id, + post_id, + ) + ) + + +async def list_conversations( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + *, + limit: int = 50, + before_updated_at: datetime | None = None, + before_conversation_id: UUID | None = None, +) -> dict[str, Any]: + """Return this account's conversations on ``post_id``, newest first.""" + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select session.post_ask_session_id, + (select left(turn.question_text, 80) + from post_ask_turn turn + where turn.post_ask_session_id = session.post_ask_session_id + order by turn.turn_ordinal + limit 1) as conversation_title, + session.updated_at, + count(turn.turn_ordinal)::int as turn_count + from post_ask_session session + left join post_ask_turn turn + on turn.post_ask_session_id = session.post_ask_session_id + where session.user_account_id = $1 + and session.post_id = $2 + and ( + $3 is null + or $4 is null + or session.updated_at < $3 + or (session.updated_at = $3 and session.post_ask_session_id < $4) + ) + group by session.post_ask_session_id, session.updated_at + order by session.updated_at desc, session.post_ask_session_id desc + limit $5 + """, + user_account_id, + post_id, + before_updated_at, + before_conversation_id, + limit + 1, + ) + page_rows = rows[:limit] + next_cursor = None + if len(rows) > limit and page_rows: + last = page_rows[-1] + next_cursor = { + "updated_at": last["updated_at"], + "conversation_id": str(last["post_ask_session_id"]), + } + return { + "conversations": [ + { + "conversation_id": str(row["post_ask_session_id"]), + "title": row["conversation_title"], + "updated_at": row["updated_at"], + "turn_count": row["turn_count"], + } + for row in page_rows + ], + "next_cursor": next_cursor, + } + + +async def _visible_post_ids_batch( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinals: list[int], + can_see_post: Callable[[asyncpg.Record], bool], + *, + source: bool, +) -> dict[int, tuple[list[str], dict[str, asyncpg.Record]]]: + """Reauthorize every turn's sources or citations in one query. + + Fetches all `turn_ordinals` at once instead of one query per turn, so a + conversation's query count stays constant regardless of how many turns + it has. Returns each turn's currently-visible post ids and rows, keyed + by turn ordinal; a turn with no visible rows still gets an empty entry. + """ + by_turn: dict[int, tuple[list[str], dict[str, asyncpg.Record]]] = { + ordinal: ([], {}) for ordinal in turn_ordinals + } + if not turn_ordinals: + return by_turn + if source: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.source_post_id::text as post_id, relation.source_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_source relation + join source_post post on post.post_id = relation.source_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.source_ordinal + """, + conversation_id, + turn_ordinals, + ) + else: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.cited_post_id::text as post_id, relation.citation_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.citation_ordinal + """, + conversation_id, + turn_ordinals, + ) + for row in rows: + if not can_see_post(row): + continue + ordinal = int(row["turn_ordinal"]) + post_id = str(row["post_id"]) + ids, id_map = by_turn[ordinal] + ids.append(post_id) + id_map[post_id] = row + return by_turn + + +async def fetch_conversation( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID, + can_see_post: Callable[[asyncpg.Record], bool], + *, + turn_limit: int = 50, + before_turn_ordinal: int | None = None, +) -> dict[str, Any] | None: + """Return one owned transcript with currently authorized citations.""" + header = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + """, + conversation_id, + user_account_id, + post_id, + ) + if header is None: + return None + + title_question = await conn.fetchval( + """ + select question_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal + limit 1 + """, + conversation_id, + ) + if before_turn_ordinal is None: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal desc + limit $2 + """, + conversation_id, + turn_limit + 1, + ) + else: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_ask_session_id = $1 + and turn_ordinal < $2 + order by turn_ordinal desc + limit $3 + """, + conversation_id, + before_turn_ordinal, + turn_limit + 1, + ) + has_older = len(turns) > turn_limit + turns = list(turns[:turn_limit]) + turns.reverse() + ordinals = [int(turn["turn_ordinal"]) for turn in turns] + sources_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=True + ) + citations_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=False + ) + exchanges: list[dict[str, Any]] = [] + for turn in turns: + ordinal = int(turn["turn_ordinal"]) + source_ids, _ = sources_by_turn[ordinal] + cited_ids, cited_rows = citations_by_turn[ordinal] + exchanges.append( + { + "turn_id": f"{conversation_id}:{ordinal}", + "question_text": turn["question_text"], + "answer_text": turn["answer_text"], + "cited_post_ids": cited_ids, + "cited_posts": [ + {"post_id": post_id_value, "post_title": cited_rows[post_id_value]["post_title"]} + for post_id_value in cited_ids + ], + "source_post_ids": source_ids, + } + ) + title = title_question[:80] if title_question else None + return { + "conversation_id": str(header["post_ask_session_id"]), + "title": title, + "exchanges": exchanges, + "older_cursor": str(turns[0]["turn_ordinal"]) if has_older and turns else None, + } + + +async def _ensure_citations_visible( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinal: int, + cited_post_count: int, + can_see_post: Callable[[asyncpg.Record], bool], +) -> None: + """Lock and re-authorize new citations before their transaction commits.""" + rows = await conn.fetch( + f""" + select relation.cited_post_id::text as post_id, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = $2 + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + for share of post + """, + conversation_id, + turn_ordinal, + ) + if len(rows) != cited_post_count or any(not can_see_post(row) for row in rows): + raise PostAskEvidenceChanged + + +async def persist_turn( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: Iterable[str], + cited_post_ids: Iterable[str], + can_see_post: Callable[[asyncpg.Record], bool] | None = None, +) -> UUID: + """Append one completed turn and return the conversation id.""" + source_ids = list(dict.fromkeys(str(post_id_value) for post_id_value in source_post_ids)) + source_set = set(source_ids) + cited_ids = list( + dict.fromkeys(str(post_id_value) for post_id_value in cited_post_ids if str(post_id_value) in source_set) + ) + async with conn.transaction(): + if conversation_id is None: + conversation_id = uuid4() + await conn.execute( + """ + insert into post_ask_session (post_ask_session_id, post_id, user_account_id) + values ($1, $2, $3) + """, + conversation_id, + post_id, + user_account_id, + ) + else: + conversation = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + for update + """, + conversation_id, + user_account_id, + post_id, + ) + if conversation is None: + raise PostAskConversationNotFound + + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from post_ask_turn where post_ask_session_id = $1", + conversation_id, + ) + ) + await conn.execute( + """ + insert into post_ask_turn + (post_ask_session_id, turn_ordinal, question_text, answer_text) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + question, + answer_text, + ) + for source_ordinal, source_post_id in enumerate(source_ids): + await conn.execute( + """ + insert into post_ask_turn_source + (post_ask_session_id, turn_ordinal, source_ordinal, source_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + source_ordinal, + source_post_id, + ) + for citation_ordinal, cited_post_id in enumerate(cited_ids): + await conn.execute( + """ + insert into post_ask_turn_citation + (post_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + citation_ordinal, + cited_post_id, + ) + await conn.execute( + "update post_ask_session set updated_at = now() where post_ask_session_id = $1", + conversation_id, + ) + if can_see_post is not None: + await _ensure_citations_visible( + conn, + conversation_id, + ordinal, + len(cited_ids), + can_see_post, + ) + assert conversation_id is not None + return conversation_id diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 45ed5d9d4..e8a483835 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -217,6 +217,11 @@ / "migrations" / "0183_source_post_event_occurred_at.sql" ) +_POST_ASK_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0223_post_ask_conversation_history.sql" +) def _postgres_available() -> bool: @@ -372,6 +377,7 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docs/adr/0228-post-ask-conversation-history.md b/docs/adr/0228-post-ask-conversation-history.md new file mode 100644 index 000000000..775599294 --- /dev/null +++ b/docs/adr/0228-post-ask-conversation-history.md @@ -0,0 +1,81 @@ +# ADR 0228: Persisted per-post Ask conversation history + +* Status: Accepted +* Date: 2026-08-23 +* Figma: File ID `1Su3lDRmiZdcUs47t1QwIX` +* Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md) + +## Context + +The post popup **Ask about this lineage** surface stored one shared +`post_chat_result` row per +`(post_id, question_norm)` and rendered a linear transcript plus re-ask +chips. Leaving the popup, switching questions, or starting a new thread +could not reopen an earlier account-owned conversation on that post. + +That is a different metaphor from the conversation-history sidebar the +reader already uses on Ask Agent. Seeded fixture answers remain the +orchestrator-off demo cache; they are not a substitute for account-owned +history. + +## Decision + +Persist per-post Ask conversations under the authenticated `user_account` +and the visible `source_post`: an explicit +conversation id, list/select/new, and visibility-filtered citations on +read. Do not represent this id as a Global Ask session id or as a fake +post-scoped orchestrator session id. + +Normalized tables: + +* `post_ask_session` — one conversation per account and post +* `post_ask_turn` — ordered questions and answers +* `post_ask_turn_citation` / `post_ask_turn_source` — cited and retrieved + posts + +The composite index `(user_account_id, post_id, updated_at desc)` leads +with the account so a hot post cannot concentrate list traffic on one +partition key. A turn is written only after a complete answer exists +(seeded cache hit or orchestrator object). History reads re-apply current +post visibility before returning titles or citations. + +`post_chat_result` stays the post-level seeded/cache store used when the +orchestrator is off. Account history is additional, not a replacement. + +## Consequences + +* A reader can list saved questions on a post, reopen one and see its + turns, and start a new conversation without losing the list. +* A user cannot read another account's post conversation by changing a + UUID, and cannot load a conversation against a different post id. +* Revoked post visibility removes that post's citation projection; the + stored answer remains account-owned transcript data. +* TEPP topic modeling of how many posts can connect, and how many + lineages form under temporal precedence, remains deferred. +* Reauthorization for a conversation's turns is batched + (`_visible_post_ids_batch`, one query per relation type per page instead + of per turn), preventing transcript length from creating an N+1 query path. +* `persist_turn` now re-authorizes every citation inside its own commit + transaction (`_ensure_citations_visible`, row-share-locked, raising + `PostAskEvidenceChanged` -> 503). A citation that loses authorization + between source-gathering and commit therefore aborts the whole turn. + +## Implementation Plan + +* **Affected paths:** `migrations/0223_post_ask_conversation_history.sql`, + `backend/app/post_ask_history.py`, `backend/app/main.py`, + `frontend/src/api.ts`, `frontend/src/App.tsx` (`ChatPanel`), + `frontend/src/i18n.ts`, `tests/test_post_ask_history.py`, + `frontend/src/ChatPanel.test.tsx` +* **Pattern:** require `post_id` and `user_account_id` scope on every query. +* **Verification:** backend tests drive `list_conversations`, + `fetch_conversation`, and `persist_turn`. Frontend tests click New + conversation, select a saved conversation, and assert the matching turns. + +## References — APA 7th + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/README.md b/docs/adr/README.md index eadef2874..144eeb426 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ decision from them. | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | +| Per-post Ask conversation history | [0228](0228-post-ask-conversation-history.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index ae3684142..56fbde830 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -70,6 +70,16 @@ above on an application-ready stack to obtain the product measurement. ## Older-image diagnostic observation +On 2026-08-26, the application-ready local synthetic stack completed a +5-VU, 15-second k6 run with five iterations and 0 failed requests. Ask enqueue +was 967.49 ms; Ask polling averaged 11.94 seconds; ordinary authenticated +posts/lineage reads averaged 34.53 seconds (p95 43.26 seconds). The run used +the stack's deployed image rather than this candidate head, so it proves only +that the existing HTTP boundary remains responsive without request failures +while also exposing unacceptable reader latency for investigation. It is not +an ADR 0228 capacity result or an SLO. Rebuild the exact candidate image and +repeat with resource and query-plan telemetry before attributing the delay. + On 2026-08-25, an application-ready local Compose stack configured with four worker VUs completed zero full iterations in two observations. In the second 30-second observation, Ask enqueue took 2.69 seconds, the maximum completed diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 7ec497477..e33e39498 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -30,5 +30,8 @@ https://www.w3.org/community/reports/design-tokens/CG-FINAL-format-20251028/ Storybook. (2026). *Storybook for React & Vite*. https://storybook.js.org/docs/get-started/frameworks/react-vite +`Workspace/ChatPanel` covers seeded-only, saved-history, and narrow mobile +states for the ADR 0228 list/select/new conversation controls. + World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/frontend/src/App.css b/frontend/src/App.css index b7482e61f..4fc37055c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1007,6 +1007,36 @@ gap: 0.5rem; } +.chat-history-controls { + display: flex; + align-items: end; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.chat-history-controls label { + display: grid; + flex: 1; + gap: var(--space-1); +} + +.chat-history-controls select { + min-height: var(--size-control-min); + width: 100%; +} + +.chat-history-controls > button { + min-height: var(--size-control-min); + white-space: nowrap; +} + +@media (max-width: 640px) { + .chat-history-controls { + align-items: stretch; + flex-direction: column; + } +} + .chat-input-row input { flex: 1; padding: 0.5rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1110d8fb0..67d7c59af 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -675,7 +675,7 @@ describe("App, authenticated", () => { JSON.stringify({ detail: payload.run_kind_code === "analysis_run_tepp" - ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement." + ? "Ask an administrator to enable measurement, then retry from the failed measurement run." : "Rebuild the period report from the Reports panel.", }), { status: 422, headers: { "Content-Type": "application/json" } }, @@ -3276,7 +3276,7 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp"); expect(list).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to see why it failed. Ask an administrator to enable measurement, then run it again.", ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); @@ -3430,11 +3430,11 @@ describe("App, authenticated", () => { ["analysis_run_lineage", "Request a new lineage reconstruction from a current snapshot."], [ "analysis_run_tepp", - "Connect the measurement service, then ask an administrator to submit a new TEPP run from a current snapshot.", + "Ask an administrator to enable measurement and submit a new run from a current snapshot.", ], [ "analysis_run_topic_lineage", - "Connect the TEPP transport, then ask an administrator to submit new topic-lineage analysis from a current snapshot.", + "Ask an administrator to enable topic-lineage analysis and submit a new run from a current snapshot.", ], ["analysis_run_report", "Rebuild the period report from a current snapshot."], ] satisfies [AnalysisRunKindCode, string][])( @@ -3453,7 +3453,7 @@ describe("App, authenticated", () => { }, ); - it("tells a running lineage run to refresh the durable outbox", async () => { + it("tells a running lineage run how to check for results", async () => { stubBackend({ runningLineageRun: true }); render(); @@ -3461,12 +3461,12 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Running · Demo Corp", }); expect(lineageButton).toHaveTextContent( - "Refresh this run. Start already queued the work on the durable outbox.", + "This run is in progress. Refresh it to check for results.", ); await userEvent.click(lineageButton); expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); expect( - screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."), + screen.getAllByText("This run is in progress. Refresh it to check for results."), ).not.toHaveLength(0); }); @@ -3486,7 +3486,7 @@ describe("App, authenticated", () => { ); expect(lineageButton).not.toHaveTextContent("measurement service"); expect(teppButton).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to see why it failed. Ask an administrator to enable measurement, then run it again.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); }); @@ -3832,7 +3832,7 @@ describe("App, authenticated", () => { ); expect( await screen.findByText( - "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.", + "Ask an administrator to enable measurement, then retry from this failed run.", ), ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e1fff1c9..8f95dd9b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,8 @@ import { fetchPostActivity, fetchPostBookmark, fetchPostChat, + fetchPostChatConversation, + fetchPostChatConversations, fetchPostAffiliateTree, fetchPostCounterparties, fetchPostEvaluation, @@ -58,6 +60,8 @@ import { type CalendarResponse, type ChatAnswer, type ChatExchange, + type PostAskConversationSummary, + type PostAskConversationPage, type CorporateEntityRef, type CustomerMasterEntity, type CustomerMasterResponse, @@ -256,7 +260,7 @@ function ChatCitations({ ); } -function ChatPanel({ +export function ChatPanel({ postId, accessToken, nameFirstAsk, @@ -267,6 +271,12 @@ function ChatPanel({ }) { const [question, setQuestion] = useState(""); const [exchanges, setExchanges] = useState([]); + const [seededExchanges, setSeededExchanges] = useState([]); + const [conversations, setConversations] = useState([]); + const [conversationCursor, setConversationCursor] = useState(null); + const [conversationOlderCursor, setConversationOlderCursor] = useState(null); + const [conversationId, setConversationId] = useState(null); + const [historyError, setHistoryError] = useState(null); const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -274,23 +284,83 @@ function ChatPanel({ const [seededOnly, setSeededOnly] = useState(false); useEffect(() => { + let active = true; setExchanges([]); + setSeededExchanges([]); + setConversations([]); + setConversationId(null); + setHistoryError(null); + setConversationCursor(null); + setConversationOlderCursor(null); setAnswer(null); setError(null); setSeededOnly(false); setEvidencePostId(null); fetchPostChat(accessToken, postId) - .then((history) => setExchanges(history.exchanges)) - .catch(() => setExchanges([])); + .then((history) => { + if (active) { + setSeededExchanges(history.exchanges); + setExchanges(history.exchanges); + } + }) + .catch(() => { if (active) setExchanges([]); }); + fetchPostChatConversations(accessToken, postId) + .then((page) => { + if (active) { + setConversations(page.conversations); + setConversationCursor(page.next_cursor ?? null); + } + }) + .catch(() => { if (active) setHistoryError(t("Conversation history could not be loaded. Start a new conversation or try again later.")); }); + return () => { active = false; }; }, [postId, accessToken]); + const conversationRequest = useRef(0); + async function selectConversation(nextId: string) { + const requestId = ++conversationRequest.current; + setHistoryError(null); + setAnswer(null); + try { + const conversation = await fetchPostChatConversation(accessToken, postId, nextId); + if (requestId !== conversationRequest.current) return; + setConversationId(conversation.conversation_id); + setExchanges(conversation.exchanges); + setConversationOlderCursor(conversation.older_cursor ?? null); + } catch { + setHistoryError(t("Conversation history could not be loaded. Start a new conversation or try again later.")); + } + } + + function startNewConversation() { + ++conversationRequest.current; + setConversationId(null); + setConversationOlderCursor(null); + setExchanges(seededExchanges); + setQuestion(""); + setAnswer(null); + setError(null); + } + async function handleAsk(asked = question) { if (!asked.trim()) return; + const startsConversation = conversationId === null; setLoading(true); setError(null); try { - const result = await askPostChat(accessToken, postId, asked); + const result = await askPostChat(accessToken, postId, asked, conversationId); setAnswer(result); + if (result.conversation_id) { + setConversationId(result.conversation_id); + setConversations((current) => [ + { + conversation_id: result.conversation_id!, + title: current.find((row) => row.conversation_id === result.conversation_id)?.title ?? asked.trim().slice(0, 80), + updated_at: new Date().toISOString(), + turn_count: (current.find((row) => row.conversation_id === result.conversation_id)?.turn_count ?? 0) + 1, + }, + ...current.filter((row) => row.conversation_id !== result.conversation_id), + ]); + } setExchanges((prev) => { const next: ChatExchange = { question_text: asked.trim(), @@ -298,7 +368,7 @@ function ChatPanel({ cited_post_ids: result.cited_post_ids, cited_posts: result.cited_posts, }; - return [...prev.filter((row) => row.question_text !== next.question_text), next]; + return startsConversation ? [next] : [...prev, next]; }); } catch (err) { setError(orchestratorUnavailableMessage(err, "Chat")); @@ -362,6 +432,54 @@ function ChatPanel({ {landedEvidenceNextAction(firstCitedTitle)}

) : null} +
+ + {conversationCursor ? ( + + ) : null} + +
+ {conversationId && conversationOlderCursor ? ( + + ) : null} + {historyError ?

{historyError}

: null} {!seededOnly && (
{selected.run_kind_code === "analysis_run_topic_lineage" - ? "Connect a TEPP transport from this Failed row. Request a " + - "lineage reconstruction does not invent a topic model." - : "Connect a TEPP transport from this Failed row. Request a lineage " + - "reconstruction does not invent a measurement."} + ? "Ask an administrator to enable topic-lineage analysis, then retry from this failed run." + : "Ask an administrator to enable measurement, then retry from this failed run."}

)} {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( diff --git a/frontend/src/ChatPanel.stories.tsx b/frontend/src/ChatPanel.stories.tsx new file mode 100644 index 000000000..395a324dd --- /dev/null +++ b/frontend/src/ChatPanel.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChatPanel } from "./App"; + +type Fixture = "empty" | "saved"; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function installFixture(fixture: Fixture) { + globalThis.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/posts/post-1/chat/conversations/conversation-post-1")) { + return jsonResponse({ + conversation_id: "conversation-post-1", + title: "Saved post question", + older_cursor: null, + exchanges: [ + { + turn_id: "turn-1", + question_text: "Which site visit was saved?", + answer_text: "The saved post answer stays grounded in the linked source.", + cited_post_ids: ["post-2"], + cited_posts: [{ post_id: "post-2", post_title: "Linked source post" }], + source_post_ids: ["post-1", "post-2"], + }, + ], + }); + } + if (url.includes("/chat/conversations")) { + return jsonResponse({ + conversations: + fixture === "saved" + ? [ + { + conversation_id: "conversation-post-1", + title: "Saved post question", + updated_at: "2026-08-21T00:00:00Z", + turn_count: 1, + }, + ] + : [], + }); + } + return jsonResponse({ + post_id: "post-1", + exchanges: [ + { + question_text: "What happened between these events?", + answer_text: "The seeded follow-up after the site visit.", + cited_post_ids: ["post-2"], + cited_posts: [{ post_id: "post-2", post_title: "Linked source post" }], + }, + ], + }); + }; +} + +const meta = { + title: "Workspace/ChatPanel", + component: ChatPanel, + args: { + postId: "post-1", + accessToken: "synthetic-story-token", + }, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SeededDump: Story = { + render(args) { + installFixture("empty"); + return ; + }, +}; + +export const SavedHistory: Story = { + render(args) { + installFixture("saved"); + return ; + }, +}; + +export const Phone: Story = { + ...SavedHistory, + parameters: { + layout: "padded", + viewport: { defaultViewport: "mobile1" }, + }, +}; diff --git a/frontend/src/ChatPanel.test.tsx b/frontend/src/ChatPanel.test.tsx new file mode 100644 index 000000000..d0d494be4 --- /dev/null +++ b/frontend/src/ChatPanel.test.tsx @@ -0,0 +1,154 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatPanel } from "./App"; +import { setLocale } from "./i18n"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + setLocale("en"); +}); + +describe("ChatPanel conversation history", () => { + it("replaces demo cache rows when the first saved turn starts", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/chat") && init?.method === "POST") { + return jsonResponse({ + answer_text: "Saved answer", + cited_post_ids: [], + cited_posts: [], + conversation_id: "conversation-2", + }); + } + if (url.endsWith("/chat/conversations")) { + return jsonResponse({ conversations: [], next_cursor: null }); + } + return jsonResponse({ + post_id: "post-1", + exchanges: [{ + question_text: "Demo question", + answer_text: "Demo answer", + cited_post_ids: [], + cited_posts: [], + }], + }); + })); + + render(); + + expect(await screen.findByText("Demo answer")).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText("What happened between these events?"), "Save this"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByText("Saved answer")).toBeInTheDocument(); + expect(screen.queryByText("Demo answer")).toBeNull(); + }); + + it("reopens an owned conversation and returns to the seeded new-conversation state", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/chat/conversations/conversation-1")) { + return jsonResponse({ + conversation_id: "conversation-1", + title: "Saved question", + older_cursor: "2", + exchanges: [{ + turn_id: "turn-1", + question_text: "What was saved?", + answer_text: "Only authorized saved evidence.", + cited_post_ids: [], + cited_posts: [], + source_post_ids: ["post-1"], + }], + }); + } + if (url.endsWith("/chat/conversations/conversation-1?before_turn=2")) { + return jsonResponse({ + conversation_id: "conversation-1", + title: "Saved question", + older_cursor: null, + exchanges: [{ + turn_id: "turn-0", + question_text: "What came first?", + answer_text: "The earlier authorized evidence.", + cited_post_ids: [], + cited_posts: [], + source_post_ids: ["post-1"], + }], + }); + } + if (url.endsWith("/chat/conversations")) { + return jsonResponse({ + conversations: [{ + conversation_id: "conversation-1", + title: "Saved question", + updated_at: "2026-08-26T00:00:00Z", + turn_count: 1, + }], + next_cursor: null, + }); + } + return jsonResponse({ + post_id: "post-1", + exchanges: [{ + question_text: "Seed question", + answer_text: "Seed answer", + cited_post_ids: [], + cited_posts: [], + }], + }); + })); + + render(); + + expect(await screen.findByText("Seed answer")).toBeInTheDocument(); + await userEvent.selectOptions( + screen.getByLabelText("Conversation history"), + "conversation-1", + ); + expect(await screen.findByText("Only authorized saved evidence.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load earlier messages" })); + expect(await screen.findByText("The earlier authorized evidence.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "New conversation" })); + await waitFor(() => expect(screen.getByText("Seed answer")).toBeInTheDocument()); + expect(screen.queryByText("Only authorized saved evidence.")).toBeNull(); + }); + + it("offers a next action when another history page cannot be loaded", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes("before_updated_at=")) throw new TypeError("network unavailable"); + if (url.endsWith("/chat/conversations")) { + return jsonResponse({ + conversations: [{ + conversation_id: "conversation-1", + title: "Saved question", + updated_at: "2026-08-26T00:00:00Z", + turn_count: 1, + }], + next_cursor: { + updated_at: "2026-08-26T00:00:00Z", + conversation_id: "conversation-1", + }, + }); + } + return jsonResponse({ post_id: "post-1", exchanges: [] }); + })); + + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Load more" })); + expect(await screen.findByText( + "Conversation history could not be loaded. Start a new conversation or try again later.", + )).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2c812ccaa..55a47b1a9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -333,6 +333,7 @@ export interface CitedPostEvidence { export interface ChatAnswer { post_id: string; + conversation_id?: string; answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; @@ -351,6 +352,25 @@ export interface ChatHistory { exchanges: ChatExchange[]; } +export interface PostAskConversationSummary { + conversation_id: string; + title: string | null; + updated_at: string; + turn_count: number; +} + +export interface PostAskConversationPage { + conversations: PostAskConversationSummary[]; + next_cursor?: { updated_at: string; conversation_id: string } | null; +} + +export interface PostAskConversation { + conversation_id: string; + title: string; + exchanges: ChatExchange[]; + older_cursor?: string | null; +} + export interface CitedPostImage { post_id: string; unit_index: number; @@ -1125,13 +1145,39 @@ export function fetchPostChat(accessToken: string, postId: string): Promise { +export function askPostChat( + accessToken: string, + postId: string, + question: string, + conversationId?: string | null, +): Promise { return backendFetch(`/api/posts/${postId}/chat`, accessToken, { method: "POST", - body: JSON.stringify({ question }), + body: JSON.stringify({ question, ...(conversationId ? { conversation_id: conversationId } : {}) }), }); } +export function fetchPostChatConversations( + accessToken: string, + postId: string, + cursor?: { updated_at: string; conversation_id: string }, +): Promise { + const query = cursor + ? `?before_updated_at=${encodeURIComponent(cursor.updated_at)}&before_conversation_id=${encodeURIComponent(cursor.conversation_id)}` + : ""; + return backendFetch(`/api/posts/${postId}/chat/conversations${query}`, accessToken); +} + +export function fetchPostChatConversation( + accessToken: string, + postId: string, + conversationId: string, + beforeTurn?: number, +): Promise { + const query = beforeTurn === undefined ? "" : `?before_turn=${beforeTurn}`; + return backendFetch(`/api/posts/${postId}/chat/conversations/${conversationId}${query}`, accessToken); +} + /** How often the queued Ask job is polled, and for how long overall. * A live orchestrator answer can take minutes under shared-gateway load, * so the ceiling is generous; the poll interval keeps the reader's diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2d19b1cb8..c09956a87 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -310,6 +310,11 @@ const TRANSLATIONS: Partial>> = { "첨부 이미지를 해독할 수 없습니다. 원문을 다시 내보내고 다시 여세요.", "What happened between these events?": "이 사건들 사이에 무슨 일이 있었나요?", "Ask about this lineage": "이 계보에 대해 질문", + "Conversation history": "대화 기록", + "Load more": "더 보기", + "Load earlier messages": "이전 메시지 불러오기", + "New conversation": "새 대화", + "Conversation history could not be loaded. Start a new conversation or try again later.": "대화 기록을 불러올 수 없습니다. 새 대화를 시작하거나 나중에 다시 시도하세요.", "No reconstructed lineage yet. Rebuild after seeding posts.": "아직 재구성된 계보가 없습니다. 글을 시드한 뒤 다시 만드세요.", "Reconstructed lineage": "재구성된 계보", @@ -810,6 +815,11 @@ const TRANSLATIONS: Partial>> = { "无法解码嵌入图像。请重新导出原始文章后再打开。", "What happened between these events?": "这些事件之间发生了什么?", "Ask about this lineage": "询问此谱系", + "Conversation history": "对话历史", + "Load more": "加载更多", + "Load earlier messages": "加载更早的消息", + "New conversation": "新对话", + "Conversation history could not be loaded. Start a new conversation or try again later.": "无法加载对话历史。请开始新对话或稍后重试。", "No reconstructed lineage yet. Rebuild after seeding posts.": "尚未重建事件谱系。生成文章种子后再重建。", "Reconstructed lineage": "已重建的事件谱系", @@ -1322,6 +1332,11 @@ const TRANSLATIONS: Partial>> = { "埋め込み画像をデコードできませんでした。原文を再エクスポートして、もう一度開いてください。", "What happened between these events?": "これらのイベントの間に何が起きましたか?", "Ask about this lineage": "この系譜について質問", + "Conversation history": "会話履歴", + "Load more": "さらに読み込む", + "Load earlier messages": "以前のメッセージを読み込む", + "New conversation": "新しい会話", + "Conversation history could not be loaded. Start a new conversation or try again later.": "会話履歴を読み込めませんでした。新しい会話を開始するか、後でもう一度お試しください。", "No reconstructed lineage yet. Rebuild after seeding posts.": "再構成された系譜はまだありません。投稿をシードしてから再構成してください。", "Reconstructed lineage": "再構成された系譜", @@ -1822,6 +1837,11 @@ const TRANSLATIONS: Partial>> = { "Không thể giải mã hình ảnh nhúng. Hãy xuất lại bài viết gốc rồi mở lại.", "What happened between these events?": "Điều gì đã xảy ra giữa các sự kiện này?", "Ask about this lineage": "Hỏi về dòng sự kiện này", + "Conversation history": "Lịch sử hội thoại", + "Load more": "Tải thêm", + "Load earlier messages": "Tải tin nhắn trước đó", + "New conversation": "Cuộc hội thoại mới", + "Conversation history could not be loaded. Start a new conversation or try again later.": "Không thể tải lịch sử hội thoại. Hãy bắt đầu cuộc hội thoại mới hoặc thử lại sau.", "No reconstructed lineage yet. Rebuild after seeding posts.": "Chưa có dòng sự kiện được tái dựng. Hãy tạo dữ liệu mồi rồi tái dựng lại.", "Reconstructed lineage": "Dòng sự kiện đã tái dựng", diff --git a/migrations/0223_post_ask_conversation_history.sql b/migrations/0223_post_ask_conversation_history.sql new file mode 100644 index 000000000..cd8e0f17f --- /dev/null +++ b/migrations/0223_post_ask_conversation_history.sql @@ -0,0 +1,48 @@ +-- ADR 0228: persist account-owned Ask conversations on each visible post. +-- Third normal form: session identity, turn text, and citation/source +-- relations are separate tables. Index leads with user_account_id so a +-- frequently asked post cannot become a single hot partition key. + +create table if not exists post_ask_session ( + post_ask_session_id uuid primary key, + post_id uuid not null references source_post(post_id) on delete cascade, + user_account_id uuid not null references user_account(user_account_id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists post_ask_session_account_post_idx + on post_ask_session (user_account_id, post_id, updated_at desc); + +create table if not exists post_ask_turn ( + post_ask_session_id uuid not null + references post_ask_session(post_ask_session_id) on delete cascade, + turn_ordinal integer not null check (turn_ordinal > 0), + question_text text not null, + answer_text text not null, + created_at timestamptz not null default now(), + primary key (post_ask_session_id, turn_ordinal) +); + +create table if not exists post_ask_turn_citation ( + post_ask_session_id uuid not null, + turn_ordinal integer not null, + citation_ordinal integer not null check (citation_ordinal >= 0), + cited_post_id uuid not null references source_post(post_id) on delete cascade, + primary key (post_ask_session_id, turn_ordinal, citation_ordinal), + foreign key (post_ask_session_id, turn_ordinal) + references post_ask_turn(post_ask_session_id, turn_ordinal) + on delete cascade +); + +create table if not exists post_ask_turn_source ( + post_ask_session_id uuid not null, + turn_ordinal integer not null, + source_ordinal integer not null check (source_ordinal >= 0), + source_post_id uuid not null references source_post(post_id) on delete cascade, + primary key (post_ask_session_id, turn_ordinal, source_ordinal), + unique (post_ask_session_id, turn_ordinal, source_post_id), + foreign key (post_ask_session_id, turn_ordinal) + references post_ask_turn(post_ask_session_id, turn_ordinal) + on delete cascade +); diff --git a/migrations/rollback/0223_post_ask_conversation_history.sql b/migrations/rollback/0223_post_ask_conversation_history.sql new file mode 100644 index 000000000..5a820c07a --- /dev/null +++ b/migrations/rollback/0223_post_ask_conversation_history.sql @@ -0,0 +1,4 @@ +drop table if exists post_ask_turn_citation; +drop table if exists post_ask_turn_source; +drop table if exists post_ask_turn; +drop table if exists post_ask_session; diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py index ef9dc9f71..b28a307e9 100644 --- a/tests/test_analysis_run_create.py +++ b/tests/test_analysis_run_create.py @@ -144,11 +144,11 @@ def test_create_rejects_tepp_and_report_kinds_without_a_fake_score() -> None: with pytest.raises(AnalysisRunCreateError) as tepp: _require_lineage_create_kind("analysis_run_tepp") assert tepp.value.status_code == 422 - assert "invent a measurement" in tepp.value.detail + assert "enable measurement" in tepp.value.detail with pytest.raises(AnalysisRunCreateError) as topic_lineage: _require_lineage_create_kind("analysis_run_topic_lineage") assert topic_lineage.value.status_code == 422 - assert "invent a topic model" in topic_lineage.value.detail + assert "enable topic-lineage analysis" in topic_lineage.value.detail with pytest.raises(AnalysisRunCreateError) as report: _require_lineage_create_kind("analysis_run_report") assert report.value.status_code == 422 @@ -180,7 +180,7 @@ async def _run() -> None: idempotency_key="client-key-1", ) assert err.value.status_code == 422 - assert "invent a measurement" in err.value.detail + assert "enable measurement" in err.value.detail asyncio.run(_run()) diff --git a/tests/test_post_ask_history.py b/tests/test_post_ask_history.py new file mode 100644 index 000000000..c15ef4993 --- /dev/null +++ b/tests/test_post_ask_history.py @@ -0,0 +1,281 @@ +"""Regression tests for account-owned per-post Ask history queries.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from backend.app.post_ask_history import ( + PostAskEvidenceChanged, + _visible_post_ids_batch, + conversation_exists, + list_conversations, + persist_turn, +) + + +class _Connection: + """Capture bound SQL calls without needing a database for query-shape tests.""" + + def __init__(self, rows: list[dict[str, object]] | None = None) -> None: + self.rows = rows or [] + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *arguments: object) -> list[dict[str, object]]: + self.calls.append((query, arguments)) + return self.rows + + async def fetchval(self, query: str, *arguments: object) -> object: + self.calls.append((query, arguments)) + if "exists(" in query: + return True + if "coalesce(max(turn_ordinal)" in query: + return 1 + return None + + async def fetchrow(self, query: str, *arguments: object) -> dict[str, object] | None: + self.calls.append((query, arguments)) + return {"post_ask_session_id": arguments[0]} + + async def execute(self, query: str, *arguments: object) -> str: + self.calls.append((query, arguments)) + return "INSERT 0 1" + + def transaction(self) -> _Connection: + return self + + async def __aenter__(self) -> _Connection: + return self + + async def __aexit__(self, *_exc: object) -> None: + return None + + +def test_list_conversations_binds_account_post_and_cursor() -> None: + """Pagination and post scope remain parameters, never interpolated SQL.""" + connection = _Connection( + [ + { + "post_ask_session_id": UUID("00000000-0000-0000-0000-000000000001"), + "conversation_title": "First", + "updated_at": datetime(2026, 1, 2, tzinfo=UTC), + "turn_count": 1, + }, + { + "post_ask_session_id": UUID("00000000-0000-0000-0000-000000000002"), + "conversation_title": "Second", + "updated_at": datetime(2026, 1, 1, tzinfo=UTC), + "turn_count": 2, + }, + ] + ) + cursor_time = datetime(2026, 1, 3, tzinfo=UTC) + + result = asyncio.run( + list_conversations( + connection, + "account-1", + "post-1", + limit=1, + before_updated_at=cursor_time, + before_conversation_id=UUID("00000000-0000-0000-0000-000000000003"), + ) + ) + + query, arguments = connection.calls[0] + assert "{cursor_clause}" not in query + assert "post_ask_session" in query + assert arguments == ( + "account-1", + "post-1", + cursor_time, + UUID("00000000-0000-0000-0000-000000000003"), + 2, + ) + assert len(result["conversations"]) == 1 + assert result["next_cursor"] is not None + assert result["conversations"][0]["conversation_id"] == "00000000-0000-0000-0000-000000000001" + + +def test_conversation_exists_requires_account_and_post() -> None: + """A conversation id from another post or account must not match.""" + connection = _Connection() + conversation_id = UUID("00000000-0000-0000-0000-000000000009") + + found = asyncio.run( + conversation_exists(connection, "account-1", "post-1", conversation_id) + ) + + query, arguments = connection.calls[0] + assert "post_ask_session" in query + assert arguments == (conversation_id, "account-1", "post-1") + assert found is True + + +def test_visible_post_ids_batch_uses_fixed_source_and_citation_queries() -> None: + """Both relation types use fixed identifiers rather than interpolated SQL.""" + for source, table, column in ( + (True, "post_ask_turn_source", "source_post_id"), + (False, "post_ask_turn_citation", "cited_post_id"), + ): + connection = _Connection( + [ + { + "turn_ordinal": 1, + "post_id": "post-1", + "post_title": "Synthetic source", + "visibility_code": "workspace", + "corporate_entity_id": "entity-1", + "author_account_id": "account-1", + "source_detail_state_code": "current", + } + ] + ) + by_turn = asyncio.run( + _visible_post_ids_batch( + connection, + UUID("00000000-0000-0000-0000-000000000004"), + [1], + lambda row: row["post_id"] == "post-1", + source=source, + ) + ) + post_ids, rows = by_turn[1] + + query, arguments = connection.calls[0] + assert table in query + assert column in query + assert "relation.{" not in query + assert "source_draft_code" in query + assert "source_deleted_flag" in query + assert "post.process_unit_id" in query + assert arguments[1] == [1] + assert post_ids == ["post-1"] + assert rows["post-1"]["post_title"] == "Synthetic source" + + +def test_visible_post_ids_batch_stays_at_one_query_regardless_of_turn_count() -> None: + """Query count must not grow with the number of turns being reauthorized.""" + connection = _Connection([]) + + by_turn = asyncio.run( + _visible_post_ids_batch( + connection, + UUID("00000000-0000-0000-0000-000000000004"), + list(range(1, 51)), + lambda row: True, + source=True, + ) + ) + + assert len(connection.calls) == 1 + assert set(by_turn.keys()) == set(range(1, 51)) + assert all(ids == [] and rows == {} for ids, rows in by_turn.values()) + + +def test_visible_post_ids_batch_never_leaks_a_row_into_the_wrong_turn() -> None: + """A row is partitioned to its own turn_ordinal, never a neighboring turn.""" + connection = _Connection( + [ + { + "turn_ordinal": 1, + "post_id": "post-turn-1", + "post_title": "Turn 1 post", + "visibility_code": "public", + "corporate_entity_id": "entity-1", + "author_account_id": "account-1", + "source_detail_state_code": "current", + }, + { + "turn_ordinal": 2, + "post_id": "post-turn-2", + "post_title": "Turn 2 post", + "visibility_code": "public", + "corporate_entity_id": "entity-1", + "author_account_id": "account-1", + "source_detail_state_code": "current", + }, + ] + ) + + by_turn = asyncio.run( + _visible_post_ids_batch( + connection, + UUID("00000000-0000-0000-0000-000000000004"), + [1, 2, 3], + lambda row: True, + source=False, + ) + ) + + assert by_turn[1][0] == ["post-turn-1"] + assert by_turn[2][0] == ["post-turn-2"] + assert by_turn[3] == ([], {}) + + +def test_persist_turn_creates_a_new_session_then_writes_the_turn() -> None: + """A completed answer is stored under the calling account and post.""" + connection = _Connection() + conversation_id = asyncio.run( + persist_turn( + connection, + "account-1", + "post-1", + None, + "Which site visit was saved?", + "The saved post answer stays grounded in the linked source.", + ["post-1", "post-2"], + ["post-2"], + ) + ) + + statements = [query for query, _arguments in connection.calls] + assert any("insert into post_ask_session" in query for query in statements) + assert any("insert into post_ask_turn" in query for query in statements) + assert any("insert into post_ask_turn_source" in query for query in statements) + assert any("insert into post_ask_turn_citation" in query for query in statements) + session_arguments = next( + arguments + for query, arguments in connection.calls + if "insert into post_ask_session" in query + ) + assert session_arguments[0] == conversation_id + assert session_arguments[1:] == ("post-1", "account-1") + + +def test_persist_turn_aborts_when_a_citation_loses_authorization() -> None: + """The transaction fails closed if cited evidence is no longer visible.""" + connection = _Connection([ + { + "post_id": "post-2", + "post_title": "Synthetic source", + "visibility_code": "workspace", + "corporate_entity_id": "entity-2", + "author_account_id": "account-2", + "source_detail_state_code": "current", + } + ]) + + with pytest.raises(PostAskEvidenceChanged): + asyncio.run( + persist_turn( + connection, + "account-1", + "post-1", + None, + "What changed?", + "A complete answer.", + ["post-1", "post-2"], + ["post-2"], + can_see_post=lambda _row: False, + ) + ) + + assert any("for share of post" in query.lower() for query, _ in connection.calls) + assert any( + "post.process_unit_id" in query and "for share of post" in query.lower() + for query, _ in connection.calls + )