Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3b087bc
feat(ask): persist per-post conversation history
Aug 26, 2026
29d05e4
docs(gap): track per-post Ask delivery evidence
Aug 26, 2026
9e2e97a
test(storybook): normalize mocked request URLs
Aug 26, 2026
c653663
fix(ask): preserve process-unit authorization context
Aug 26, 2026
51b9dfa
test(storybook): accept every fetch input shape
Aug 26, 2026
70af96b
fix(ask): preserve localized and race-safe conversation history
Aug 26, 2026
4260af2
fix(ui): make recovery copy customer-actionable
Aug 26, 2026
2783500
fix(ask): complete conversation pagination recovery
Aug 26, 2026
aede591
Merge branch 'chore/refresh-gap-baseline-20260826' into feat/post-ask…
seonghobae Aug 26, 2026
11f7643
fix(chat): separate saved turns from demo cache
Aug 26, 2026
9dfa784
docs(gaps): record saved-turn cache boundary
Aug 26, 2026
6cd0b14
feat(ask): persist per-post conversation history
Aug 26, 2026
6bf2373
Merge remote-tracking branch 'origin/chore/refresh-gap-baseline-20260…
Aug 26, 2026
d19808f
fix(ask): preserve localized and race-safe conversation history
Aug 26, 2026
d9d14c0
Merge remote-tracking branch 'origin/feat/post-ask-conversation-histo…
Aug 26, 2026
eb4d693
Merge remote-tracking branch 'origin/chore/refresh-gap-baseline-20260…
Aug 26, 2026
d3c96fa
Merge remote-tracking branch 'origin/chore/refresh-gap-baseline-20260…
Aug 26, 2026
17a1313
fix(ui): make recovery copy customer-actionable
Aug 26, 2026
4597722
docs(gaps): record customer recovery copy head
Aug 26, 2026
bf7d6b8
Merge remote-tracking branch 'origin/chore/refresh-gap-baseline-20260…
Aug 26, 2026
f3da213
style(chat): reuse touch-target token
Aug 26, 2026
0ba4e60
test(chat): use canonical history cursor fields
Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
132 changes: 131 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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"]),
)
Comment thread
seonghobae marked this conversation as resolved.
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(
Expand Down Expand Up @@ -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,
)
Comment thread
seonghobae marked this conversation as resolved.
await publish_activity_event(
valkey,
post_id,
Expand All @@ -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,
Expand Down
Loading