feat(pilot): Phase 2 — What we know (facts) with stable task-lane IDs
Adds the load-bearing structural feature of the FlowPilot migration: a
"What we know" panel that holds confirmed facts for a session, fed by AI
[PROMOTE] markers and engineer-added notes. Facts feed the resolution
note preview (Phase 3) and survive across turns via stable UUIDs assigned
to pending_task_lane items.
Backend:
- FactSynthesisService: create/update/soft-delete facts with atomic
state_version bumps; LLM-backed synthesize_from_question/check on the
fact_synthesis (Haiku) action tier per Section 6.6.
- /api/v1/ai-sessions/{id}/facts CRUD + /facts/promote (proposed_text or
via synthesis). PATCH returns 403 for question/diagnostic_check facts
(edit the source item instead, Section 7.3).
- unified_chat_service: [PROMOTE] marker parser (JSON-block per Section
8.1 spec drift note), stable-UUID assignment for pending_task_lane
questions/actions preserved by exact text/label match across turns.
- ASSISTANT_SYSTEM_PROMPT: documents [PROMOTE] format, when to/not to
emit, hallucination guardrails, source_ref handling.
- 17 tests covering parser, stable IDs, service validation, CRUD,
editability rule, both promote modes, 422 null-synthesis path,
state_version invariant.
Frontend:
- src/components/pilot/sections/{WhatWeKnow,WhatWeKnowItem,AddNoteButton}
— green-gradient section above Questions, dashed-circle check, inline
edit/delete gated by the server's editable flag.
- TaskLane gains a whatWeKnowSlot prop (existing assistant/ folder kept
per the doc's "rename is opportunistic" guidance).
- AssistantChatPage fetches facts on selectChat and refetches after each
chat send (so [PROMOTE]-synthesized facts appear immediately); auto-
opens the lane when facts exist.
Verification: end-to-end smoke against the local docker stack confirms
all five endpoints (list/create/patch/delete/promote) plus the 403
editability rule. pytest suite verifies the same with mocked LLM. Live
[PROMOTE] flow remains untested until used in the UI — the marker shape
is covered by parser tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,9 +62,12 @@ Every response you write MUST follow this exact structure:
|
||||
1. **1-3 sentences of analysis** (what the symptoms tell you)
|
||||
2. **[QUESTIONS] marker** with 1-3 questions for the engineer (if you need info)
|
||||
3. **[ACTIONS] marker** with 1-4 diagnostic commands to run (if applicable)
|
||||
4. **[PROMOTE] marker(s)** when the engineer's most recent message confirmed a fact \
|
||||
worth recording (optional; see "Promoting facts" below)
|
||||
|
||||
You MUST include at least one marker ([QUESTIONS] or [ACTIONS]) in every response. \
|
||||
A response with only prose and no markers is INVALID and will break the UI.
|
||||
A response with only prose and no markers is INVALID and will break the UI. \
|
||||
[PROMOTE] is optional and IN ADDITION to the required markers, never a replacement.
|
||||
|
||||
### Complete example of a correct first response:
|
||||
|
||||
@@ -112,6 +115,50 @@ information is no longer needed to resolve the issue. Default to keeping them.
|
||||
**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \
|
||||
not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response.
|
||||
|
||||
## Promoting facts to "What we know"
|
||||
|
||||
The engineer has a "What we know" panel that holds confirmed facts about this \
|
||||
session. Each confirmed fact stays visible to the engineer for the rest of the \
|
||||
session and feeds the resolution note posted to the customer ticket. Surface \
|
||||
facts there using a `[PROMOTE]` marker.
|
||||
|
||||
**When to emit [PROMOTE]:**
|
||||
- The engineer just answered a [QUESTIONS] item with a substantive answer that \
|
||||
rules something in or out
|
||||
- The engineer just shared diagnostic-check output that confirmed a finding
|
||||
- You synthesized a new conclusion from two or more prior facts
|
||||
|
||||
**When NOT to emit [PROMOTE]:**
|
||||
- The engineer's answer was "unknown", "I don't know", or a clarifying question \
|
||||
back to you
|
||||
- The diagnostic output was empty, errored, or inconclusive
|
||||
- You're re-stating something already in What we know
|
||||
- The "fact" is your own hypothesis, not something the engineer confirmed
|
||||
|
||||
**[PROMOTE] marker format:**
|
||||
Each fact is its own block. You may emit multiple blocks per response.
|
||||
|
||||
[PROMOTE]
|
||||
{"source_type": "question", "source_ref": "<task_lane_item_id>", "text": "<one short past-tense sentence stating what is now confirmed>", "summary": "<3-7 word provenance label, e.g. 'rules out tenant/license'>"}
|
||||
[/PROMOTE]
|
||||
|
||||
- `source_type` is one of: `"question"` (fact derived from a question's answer), \
|
||||
`"diagnostic_check"` (fact derived from a check's output), or `"ai_synthesis"` \
|
||||
(you combined prior facts).
|
||||
- `source_ref` is the `id` field of the originating task-lane item — the \
|
||||
[QUESTIONS] and [ACTIONS] payloads you receive in conversation context include \
|
||||
an `id` for each item. Copy that UUID verbatim. For `ai_synthesis`, OMIT \
|
||||
`source_ref` (or set it to null).
|
||||
- `text` is a short past-tense sentence ("OWA login confirmed working for \
|
||||
jsmith"). Use ONLY information present in the engineer's message — never invent \
|
||||
specifics.
|
||||
- `summary` names the diagnostic value (what the fact rules in or out), 3-7 \
|
||||
words, no period.
|
||||
|
||||
**Strict rule:** [PROMOTE] is for confirmed facts only. If you're not certain \
|
||||
the engineer's message confirms the fact, do not emit a [PROMOTE]. Hallucinated \
|
||||
facts get posted to customer tickets and will erode trust in the system.
|
||||
|
||||
## Using the Team's Flow Library
|
||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||
appear in the context below, reference them by name so the engineer can launch them \
|
||||
@@ -182,6 +229,9 @@ No exceptions. Not even when forking. A response without at least one of these m
|
||||
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
|
||||
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
|
||||
in your markers unless you are ≥75% confident that information is no longer relevant.
|
||||
[PROMOTE] markers are OPTIONAL and IN ADDITION to the required ones — emit them only \
|
||||
when the engineer's most recent message confirmed something worth recording, and copy \
|
||||
the originating item's `id` into `source_ref` verbatim.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
285
backend/app/services/fact_synthesis_service.py
Normal file
285
backend/app/services/fact_synthesis_service.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""FactSynthesisService — converts engineer answers and check output into facts.
|
||||
|
||||
Two paths feed this service:
|
||||
|
||||
1. **AI marker path (the common case).** When the model emits a `[PROMOTE]`
|
||||
marker in the chat stream, `unified_chat_service` parses the marker (which
|
||||
already contains the engineer-readable `text` and short provenance `summary`)
|
||||
and calls `create_fact` directly. No LLM call is needed — the model already
|
||||
wrote the fact.
|
||||
|
||||
2. **Engineer-driven synthesize path.** The "+ Promote to What we know" affordance
|
||||
in the UI sends a raw answer or check output and asks the server to draft
|
||||
`text` + `summary` for review. `synthesize_from_question` /
|
||||
`synthesize_from_check` make a small Haiku call for that draft. The engineer
|
||||
confirms (or edits) before persistence, so the LLM output is never
|
||||
silently posted to a customer ticket.
|
||||
|
||||
Either way, persistence funnels through `create_fact`, which ALSO bumps
|
||||
`ai_sessions.state_version` so the resolution-note preview cache invalidates
|
||||
(see FLOWPILOT-MIGRATION.md Section 5.5).
|
||||
|
||||
Model tier is `fact_synthesis` in `settings.ACTION_MODEL_MAP` (Haiku per
|
||||
Section 6.6). MCP is intentionally disabled for synthesis — these are
|
||||
pure transformations of input, not research calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_fact import SessionFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Conservative synthesis prompt. Hallucinated specifics are a trust-killer
|
||||
# because facts feed the resolution note posted to customer tickets — the
|
||||
# prompt makes "no fact" an explicit, valid output.
|
||||
_SYNTHESIS_SYSTEM_PROMPT = """\
|
||||
You convert one engineer answer or one diagnostic-check output into a single \
|
||||
candidate fact for a troubleshooting session's "What we know" log.
|
||||
|
||||
Return strict JSON with this shape:
|
||||
{
|
||||
"text": "<one short sentence stating what is now known, in past tense>",
|
||||
"summary": "<3-7 word provenance label, e.g. 'rules out tenant/license'>"
|
||||
}
|
||||
|
||||
If the answer/output does NOT contain a substantive fact (e.g. the engineer \
|
||||
typed 'unknown', the command failed, the output is empty), return:
|
||||
{
|
||||
"text": null,
|
||||
"summary": null
|
||||
}
|
||||
|
||||
Strict rules:
|
||||
- Use ONLY information present in the input. Never add details that were not stated.
|
||||
- Do not speculate, infer causes, or extrapolate. State only what the input proves.
|
||||
- The text is a fact a colleague could verify by looking at the original answer/output.
|
||||
- The summary names the diagnostic value (what this fact rules in or out), not the topic.
|
||||
- Output ONLY the JSON object, no prose, no markdown fences.
|
||||
"""
|
||||
|
||||
|
||||
class FactSynthesisService:
|
||||
"""Persists session facts and (optionally) drafts them via an LLM call.
|
||||
|
||||
Methods that touch the database take an `AsyncSession` and assume the
|
||||
caller commits. `create_fact` flushes so the returned row has a primary key.
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── Persistence ────────────────────────────────────────────────────────
|
||||
|
||||
async def create_fact(
|
||||
self,
|
||||
*,
|
||||
session_id: UUID,
|
||||
account_id: UUID,
|
||||
user_id: UUID,
|
||||
source_type: str,
|
||||
text: str,
|
||||
summary: str | None = None,
|
||||
source_ref: UUID | None = None,
|
||||
) -> SessionFact:
|
||||
"""Persist a fact and bump the session's preview-cache version.
|
||||
|
||||
`source_ref` MUST be None for `user_note` and `ai_synthesis` sources;
|
||||
for `question` and `diagnostic_check` it should point at the stable
|
||||
UUID of the originating task-lane item. The DB has no FK constraint
|
||||
on `source_ref` (the target lives inside JSONB) — integrity is enforced
|
||||
here.
|
||||
"""
|
||||
if source_type not in ("question", "diagnostic_check", "user_note", "ai_synthesis"):
|
||||
raise ValueError(f"Invalid source_type: {source_type}")
|
||||
|
||||
if source_type in ("user_note", "ai_synthesis") and source_ref is not None:
|
||||
# `source_ref` is a back-pointer to a question/check; user notes
|
||||
# and AI-synthesized facts have no source item to point at.
|
||||
raise ValueError(
|
||||
f"source_ref must be None for source_type={source_type}"
|
||||
)
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
raise ValueError("Fact text cannot be empty")
|
||||
|
||||
fact = SessionFact(
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
text=text,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
source_summary=(summary or "").strip() or None,
|
||||
created_by=user_id,
|
||||
)
|
||||
self.db.add(fact)
|
||||
|
||||
# Invalidate any preview cached against the prior state_version.
|
||||
# Single UPDATE so the bump is atomic relative to the fact insert
|
||||
# within this transaction; concurrent writers serialize on the row.
|
||||
await self.db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
await self.db.flush()
|
||||
return fact
|
||||
|
||||
async def soft_delete_fact(self, fact: SessionFact) -> None:
|
||||
"""Mark a fact deleted and bump state_version."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
fact.deleted_at = datetime.now(timezone.utc)
|
||||
await self.db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == fact.session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
await self.db.flush()
|
||||
|
||||
async def update_fact(
|
||||
self,
|
||||
fact: SessionFact,
|
||||
*,
|
||||
text: str | None = None,
|
||||
summary: str | None = None,
|
||||
) -> SessionFact:
|
||||
"""Update an editable fact and bump state_version.
|
||||
|
||||
Caller is responsible for the editability check — only `user_note`
|
||||
and `ai_synthesis` facts may be edited at the card level. The
|
||||
endpoint enforces this and returns 403 for the read-only types.
|
||||
"""
|
||||
if text is not None:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Fact text cannot be empty")
|
||||
fact.text = stripped
|
||||
if summary is not None:
|
||||
fact.source_summary = summary.strip() or None
|
||||
|
||||
await self.db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == fact.session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
await self.db.flush()
|
||||
return fact
|
||||
|
||||
# ── LLM-backed drafting ────────────────────────────────────────────────
|
||||
|
||||
async def synthesize_from_question(
|
||||
self, *, question_text: str, raw_answer: str
|
||||
) -> dict[str, str | None]:
|
||||
"""Draft `{text, summary}` from a question + engineer's free-text answer.
|
||||
|
||||
Returns `{"text": None, "summary": None}` when the answer doesn't
|
||||
contain a substantive fact — caller should not persist in that case.
|
||||
"""
|
||||
return await self._synthesize(
|
||||
user_input=(
|
||||
f"Question asked: {question_text.strip()}\n"
|
||||
f"Engineer's answer: {raw_answer.strip()}"
|
||||
),
|
||||
)
|
||||
|
||||
async def synthesize_from_check(
|
||||
self, *, check_label: str, check_output: str
|
||||
) -> dict[str, str | None]:
|
||||
"""Draft `{text, summary}` from a diagnostic check label + its output."""
|
||||
return await self._synthesize(
|
||||
user_input=(
|
||||
f"Diagnostic check: {check_label.strip()}\n"
|
||||
f"Output:\n{check_output.strip()}"
|
||||
),
|
||||
)
|
||||
|
||||
async def _synthesize(self, *, user_input: str) -> dict[str, str | None]:
|
||||
"""Single Haiku call with the conservative synthesis prompt."""
|
||||
model = settings.get_model_for_action("fact_synthesis")
|
||||
provider = get_ai_provider(model=model)
|
||||
|
||||
# Cache the system prompt — it's identical across every synthesis call.
|
||||
system_blocks: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": _SYNTHESIS_SYSTEM_PROMPT,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
# cacheable: identical across all fact-synthesis calls
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
text, _in, _out = await provider.generate_json(
|
||||
system_prompt=system_blocks,
|
||||
messages=[{"role": "user", "content": user_input}],
|
||||
max_tokens=200,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Fact synthesis LLM call failed")
|
||||
return {"text": None, "summary": None}
|
||||
|
||||
return self._parse_synthesis_response(text)
|
||||
|
||||
@staticmethod
|
||||
def _parse_synthesis_response(raw: str) -> dict[str, str | None]:
|
||||
"""Tolerant parse: strip fences, accept null fields, ignore extras."""
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||||
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Fact synthesis returned non-JSON: %r", raw[:200])
|
||||
return {"text": None, "summary": None}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"text": None, "summary": None}
|
||||
|
||||
text = data.get("text")
|
||||
summary = data.get("summary")
|
||||
if text is not None and not isinstance(text, str):
|
||||
text = None
|
||||
if summary is not None and not isinstance(summary, str):
|
||||
summary = None
|
||||
|
||||
# Treat empty strings the same as null — "no substantive fact".
|
||||
if isinstance(text, str) and not text.strip():
|
||||
text = None
|
||||
if isinstance(summary, str) and not summary.strip():
|
||||
summary = None
|
||||
|
||||
return {"text": text, "summary": summary}
|
||||
|
||||
|
||||
async def list_facts_for_session(
|
||||
db: AsyncSession, session_id: UUID
|
||||
) -> list[SessionFact]:
|
||||
"""List non-deleted facts for a session, oldest first.
|
||||
|
||||
RLS filters by tenant; the explicit account_id check is unnecessary here.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SessionFact)
|
||||
.where(
|
||||
SessionFact.session_id == session_id,
|
||||
SessionFact.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(SessionFact.created_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -3,10 +3,19 @@
|
||||
Replaces assistant_chat_service for new chat sessions. Messages are stored
|
||||
in ai_sessions.conversation_messages JSONB. Reuses the same AI calling
|
||||
infrastructure and system prompt from assistant_chat_service.
|
||||
|
||||
## Markers parsed here
|
||||
- `[QUESTIONS]` / `[ACTIONS]` — task-lane items shown to the engineer
|
||||
- `[FORK]` — diagnostic forking, creates SessionBranch rows
|
||||
- `[PROMOTE]` (Phase 2) — surfaces a fact to the What-we-know section.
|
||||
Items in pending_task_lane carry stable UUIDs (assigned here) so PROMOTE
|
||||
source_refs survive across turns even when the model re-emits the same
|
||||
question/action.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid as _uuid
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -19,6 +28,7 @@ from app.services.assistant_chat_service import (
|
||||
_call_ai,
|
||||
_auto_title,
|
||||
)
|
||||
from app.services.fact_synthesis_service import FactSynthesisService
|
||||
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -147,6 +157,176 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
|
||||
return cleaned, valid_questions
|
||||
|
||||
|
||||
def _parse_promote_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]:
|
||||
"""Extract one or more [PROMOTE]...[/PROMOTE] JSON blocks from AI response.
|
||||
|
||||
Each block contains a JSON object describing a candidate fact:
|
||||
{"source_type": "question"|"diagnostic_check"|"ai_synthesis",
|
||||
"source_ref": "<task_lane_item_uuid>" | null,
|
||||
"text": "<fact text>",
|
||||
"summary": "<short provenance, optional>"}
|
||||
|
||||
Returns (cleaned_content, list_of_items_or_None). All matched blocks are
|
||||
stripped from display text. Invalid items are dropped silently with a
|
||||
warning — a malformed PROMOTE should never break the chat response.
|
||||
|
||||
Per FLOWPILOT-MIGRATION.md Section 8.1, the model emits text + summary
|
||||
inline so no LLM round-trip is needed to persist the fact.
|
||||
"""
|
||||
blocks = list(re.finditer(r"\[PROMOTE\]\s*([\s\S]*?)\s*\[/PROMOTE\]", ai_content))
|
||||
if not blocks:
|
||||
return ai_content, None
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for m in blocks:
|
||||
raw = m.group(1).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||
raw = re.sub(r"\s*```$", "", raw)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [PROMOTE] block: %s", e)
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("[PROMOTE] block must be a JSON object, got %s", type(data).__name__)
|
||||
continue
|
||||
|
||||
source_type = data.get("source_type")
|
||||
text = (data.get("text") or "").strip()
|
||||
summary = (data.get("summary") or "").strip() or None
|
||||
source_ref_raw = data.get("source_ref")
|
||||
|
||||
if source_type not in ("question", "diagnostic_check", "ai_synthesis"):
|
||||
# `user_note` is engineer-only, not an AI-emittable type.
|
||||
logger.warning("Invalid [PROMOTE] source_type=%r, skipping", source_type)
|
||||
continue
|
||||
if not text:
|
||||
logger.warning("[PROMOTE] block missing text, skipping")
|
||||
continue
|
||||
|
||||
source_ref: UUID | None = None
|
||||
if source_ref_raw:
|
||||
try:
|
||||
source_ref = UUID(str(source_ref_raw))
|
||||
except (ValueError, AttributeError):
|
||||
logger.warning("[PROMOTE] source_ref %r is not a valid UUID, dropping ref", source_ref_raw)
|
||||
source_ref = None
|
||||
|
||||
# `ai_synthesis` must NEVER carry a source_ref (no question/check item
|
||||
# to point at) — surface mistakes from the model rather than tripping
|
||||
# the FactSynthesisService validation later.
|
||||
if source_type == "ai_synthesis":
|
||||
source_ref = None
|
||||
|
||||
items.append({
|
||||
"source_type": source_type,
|
||||
"source_ref": source_ref,
|
||||
"text": text,
|
||||
"summary": summary,
|
||||
})
|
||||
|
||||
# Strip all PROMOTE blocks from display content — engineers see facts in
|
||||
# the What-we-know panel, not as raw markers in the chat.
|
||||
cleaned = re.sub(r"\[PROMOTE\]\s*[\s\S]*?\s*\[/PROMOTE\]", "", ai_content).strip()
|
||||
|
||||
return cleaned, items or None
|
||||
|
||||
|
||||
def _assign_stable_task_lane_ids(
|
||||
prev_lane: dict[str, Any] | None,
|
||||
questions: list[dict[str, Any]] | None,
|
||||
actions: list[dict[str, Any]] | None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Assign stable UUIDs to task-lane items, preserving them across turns.
|
||||
|
||||
The model often re-emits the same question/action across multiple turns
|
||||
(it is told to keep `_(not yet completed)_` items alive). When the
|
||||
question text matches a prior turn's, we keep the prior UUID so any
|
||||
`session_facts.source_ref` pointing at it stays valid.
|
||||
|
||||
Match key:
|
||||
- Questions: exact `text`
|
||||
- Actions: exact `label`
|
||||
|
||||
Returns the questions/actions lists augmented with an `id` field.
|
||||
"""
|
||||
prev_questions = (prev_lane or {}).get("questions") or []
|
||||
prev_actions = (prev_lane or {}).get("actions") or []
|
||||
|
||||
prev_q_ids: dict[str, str] = {
|
||||
str(q.get("text") or "").strip(): str(q["id"])
|
||||
for q in prev_questions
|
||||
if isinstance(q, dict) and q.get("id") and q.get("text")
|
||||
}
|
||||
prev_a_ids: dict[str, str] = {
|
||||
str(a.get("label") or "").strip(): str(a["id"])
|
||||
for a in prev_actions
|
||||
if isinstance(a, dict) and a.get("id") and a.get("label")
|
||||
}
|
||||
|
||||
out_questions: list[dict[str, Any]] = []
|
||||
for q in questions or []:
|
||||
text = str(q.get("text") or "").strip()
|
||||
existing = prev_q_ids.get(text) if text else None
|
||||
out_questions.append({
|
||||
**q,
|
||||
"id": existing or str(_uuid.uuid4()),
|
||||
})
|
||||
|
||||
out_actions: list[dict[str, Any]] = []
|
||||
for a in actions or []:
|
||||
label = str(a.get("label") or "").strip()
|
||||
existing = prev_a_ids.get(label) if label else None
|
||||
out_actions.append({
|
||||
**a,
|
||||
"id": existing or str(_uuid.uuid4()),
|
||||
})
|
||||
|
||||
return out_questions, out_actions
|
||||
|
||||
|
||||
async def _persist_promote_items(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
session: AISession,
|
||||
user_id: UUID,
|
||||
items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Persist parsed [PROMOTE] items as session_facts. Failures are logged.
|
||||
|
||||
A malformed PROMOTE must never break the chat response — the engineer
|
||||
still gets the AI's analysis; the missing fact can be added manually.
|
||||
"""
|
||||
if not items:
|
||||
return
|
||||
service = FactSynthesisService(db)
|
||||
for item in items:
|
||||
try:
|
||||
await service.create_fact(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
user_id=user_id,
|
||||
source_type=item["source_type"],
|
||||
text=item["text"],
|
||||
summary=item["summary"],
|
||||
source_ref=item["source_ref"],
|
||||
)
|
||||
except ValueError:
|
||||
# Validation failure (e.g. empty text after strip, or
|
||||
# source_ref-on-ai_synthesis race). Log and continue — losing
|
||||
# one fact is better than aborting the whole chat turn.
|
||||
logger.warning(
|
||||
"Skipping invalid PROMOTE item for session %s: %r",
|
||||
session.id, item, exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to persist PROMOTE item for session %s", session.id
|
||||
)
|
||||
|
||||
|
||||
async def create_chat_session(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
@@ -251,10 +431,11 @@ async def send_chat_message(
|
||||
if session.status == "paused":
|
||||
session.status = "active"
|
||||
|
||||
# Check for fork, actions, and questions markers in branch response too
|
||||
# Check for fork, actions, questions, and promote markers in branch response too
|
||||
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
|
||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
||||
branch_display, branch_promote_items = _parse_promote_marker(branch_display)
|
||||
if branch_display != ai_content:
|
||||
# Store stripped content in branch history
|
||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||
@@ -288,15 +469,30 @@ async def send_chat_message(
|
||||
except Exception:
|
||||
logger.exception("Failed to create fork within branch for session %s", session.id)
|
||||
|
||||
# Persist task lane state on session
|
||||
# Persist task lane state on session — assign stable UUIDs so any
|
||||
# PROMOTE marker emitted later can reference the same items.
|
||||
if branch_questions_data or branch_actions_data:
|
||||
stable_qs, stable_as = _assign_stable_task_lane_ids(
|
||||
session.pending_task_lane,
|
||||
branch_questions_data,
|
||||
branch_actions_data,
|
||||
)
|
||||
session.pending_task_lane = {
|
||||
"questions": branch_questions_data or [],
|
||||
"actions": branch_actions_data or [],
|
||||
"questions": stable_qs,
|
||||
"actions": stable_as,
|
||||
}
|
||||
else:
|
||||
session.pending_task_lane = None
|
||||
|
||||
# Persist any PROMOTE items emitted in this turn. Done AFTER the
|
||||
# task-lane write so source_refs to brand-new items would still
|
||||
# land on persisted UUIDs (the model can also reference IDs from
|
||||
# the previous turn, which were already persisted).
|
||||
if branch_promote_items:
|
||||
await _persist_promote_items(
|
||||
db=db, session=session, user_id=user_id, items=branch_promote_items,
|
||||
)
|
||||
|
||||
suggested_flows = extract_suggested_flows(
|
||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
||||
)
|
||||
@@ -343,9 +539,13 @@ async def send_chat_message(
|
||||
# Check for questions marker in AI response
|
||||
display_content, questions_data = _parse_questions_marker(display_content)
|
||||
|
||||
# Check for promote markers — facts the AI is surfacing to What we know.
|
||||
display_content, promote_items = _parse_promote_marker(display_content)
|
||||
|
||||
logger.info(
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, promote: %d, raw_length: %d, display_length: %d",
|
||||
bool(actions_data), bool(questions_data), bool(fork_data),
|
||||
len(promote_items or []),
|
||||
len(ai_content), len(display_content),
|
||||
)
|
||||
|
||||
@@ -410,15 +610,26 @@ async def send_chat_message(
|
||||
logger.exception("Failed to create fork for session %s", session_id)
|
||||
# Fork failed but chat message still sent — don't break the response
|
||||
|
||||
# Persist task lane state on session
|
||||
# Persist task lane state on session — assign stable UUIDs so any PROMOTE
|
||||
# marker (this turn or a later one) can reference the same items.
|
||||
if questions_data or actions_data:
|
||||
stable_qs, stable_as = _assign_stable_task_lane_ids(
|
||||
session.pending_task_lane, questions_data, actions_data,
|
||||
)
|
||||
session.pending_task_lane = {
|
||||
"questions": questions_data or [],
|
||||
"actions": actions_data or [],
|
||||
"questions": stable_qs,
|
||||
"actions": stable_as,
|
||||
}
|
||||
else:
|
||||
session.pending_task_lane = None
|
||||
|
||||
# Persist any PROMOTE items emitted in this turn. Done after task-lane
|
||||
# assignment so source_refs the model invented this turn already exist.
|
||||
if promote_items:
|
||||
await _persist_promote_items(
|
||||
db=db, session=session, user_id=user_id, items=promote_items,
|
||||
)
|
||||
|
||||
suggested_flows = extract_suggested_flows(rag_results)
|
||||
|
||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||
|
||||
Reference in New Issue
Block a user