+
+ }
+ />
+ } />
+
+ ,
+ )
+
+ // The protected page should not render.
+ expect(screen.queryByTestId('home-content')).not.toBeInTheDocument()
+
+ // We landed on / (the public landing route), not /landing.
+ expect(screen.getByTestId('probe-pathname')).toHaveTextContent('/')
+ expect(screen.getByTestId('probe-from')).toHaveTextContent('/home')
+ })
+})
diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx
index 4e6f6fca..bcd214de 100644
--- a/frontend/src/pages/AssistantChatPage.tsx
+++ b/frontend/src/pages/AssistantChatPage.tsx
@@ -2416,7 +2416,7 @@ export default function AssistantChatPage() {
setShowConclude(false)
if (activeSessionStatus === 'escalated') {
toast.info('Session escalated. Heading back to your dashboard.')
- navigate('/')
+ navigate('/home')
}
}}
onConclude={handleConclude}
diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx
index e7d68adf..dade0f1a 100644
--- a/frontend/src/pages/ContactPage.tsx
+++ b/frontend/src/pages/ContactPage.tsx
@@ -7,7 +7,7 @@ export default function ContactPage() {
- ← Back to home
+ ← Back to home
Contact ResolutionFlow
We respond to customer inquiries Monday through Friday during U.S. business hours, excluding federal holidays. Email is the fastest path to a response.
diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx
index b32dd080..e606ac27 100644
--- a/frontend/src/pages/OAuthCallbackPage.tsx
+++ b/frontend/src/pages/OAuthCallbackPage.tsx
@@ -112,10 +112,10 @@ export function OAuthCallbackPage() {
// Invitee path lands on the dashboard with the teammate-welcome
// marker; new self-serve owners go to the welcome wizard; returning
- // users to /.
- let dest = '/'
+ // users to /home.
+ let dest = '/home'
if (decoded?.accountInviteCode) {
- dest = '/?welcome=teammate'
+ dest = '/home?welcome=teammate'
} else if (result.is_new_user) {
dest = '/welcome'
}
diff --git a/frontend/src/pages/PoliciesPage.tsx b/frontend/src/pages/PoliciesPage.tsx
index eaabb472..1da097cd 100644
--- a/frontend/src/pages/PoliciesPage.tsx
+++ b/frontend/src/pages/PoliciesPage.tsx
@@ -7,7 +7,7 @@ export default function PoliciesPage() {
- ← Back to home
+ ← Back to home
Customer Policies
Last updated: May 7, 2026
Operator: ResolutionFlow, LLC (the “Company”), operator of ResolutionFlow (“Service”).
diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx
index 1478bbca..9a6fc5f3 100644
--- a/frontend/src/pages/PrivacyPage.tsx
+++ b/frontend/src/pages/PrivacyPage.tsx
@@ -7,7 +7,7 @@ export default function PrivacyPage() {
- ← Back to home
+ ← Back to home
Privacy Policy
Last updated: March 21, 2026
diff --git a/frontend/src/pages/PromotionsPage.tsx b/frontend/src/pages/PromotionsPage.tsx
index 132ad10b..f495f1dd 100644
--- a/frontend/src/pages/PromotionsPage.tsx
+++ b/frontend/src/pages/PromotionsPage.tsx
@@ -7,7 +7,7 @@ export default function PromotionsPage() {
} />
,
)
@@ -100,7 +100,7 @@ describe('WelcomeRouter', () => {
})
})
- it('redirects to / when onboarding_step_completed >= 3', async () => {
+ it('redirects to /home when onboarding_step_completed >= 3', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 3 }),
})
@@ -110,7 +110,7 @@ describe('WelcomeRouter', () => {
})
})
- it('redirects to / when onboarding_dismissed is true', async () => {
+ it('redirects to /home when onboarding_dismissed is true', async () => {
useAuthStore.setState({
user: makeUser({
onboarding_step_completed: 1,
diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
index 93483add..a067d171 100644
--- a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
+++ b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
@@ -65,7 +65,7 @@ function renderPage() {
} />
step-2
} />
- dashboard
} />
+ dashboard
} />
,
)
@@ -148,7 +148,7 @@ describe('WelcomeStep1', () => {
})
})
- it('Skip-the-rest dismisses and navigates to /', async () => {
+ it('Skip-the-rest dismisses and navigates to /home', async () => {
const user = userEvent.setup()
renderPage()
diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
index 57b2a9bd..3cb5c9ef 100644
--- a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
+++ b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
@@ -66,7 +66,7 @@ function renderPage() {
} />
step-3
} />
integrations
} />
- dashboard
} />
+ dashboard
} />
,
)
@@ -158,7 +158,7 @@ describe('WelcomeStep2', () => {
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
})
- it('Skip-the-rest dismisses and navigates to /', async () => {
+ it('Skip-the-rest dismisses and navigates to /home', async () => {
const user = userEvent.setup()
renderPage()
--
2.49.1
From f9f98b1a65c276140727a779d997ab0800fd71ba Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Fri, 15 May 2026 00:34:23 -0400
Subject: [PATCH 4/9] fix(routing): finish /home migration in WelcomeStep3 +
VerifyEmailPage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The original public-landing routing refactor migrated WelcomeRouter,
WelcomeStep1, and WelcomeStep2 post-onboarding redirects to /home, but
left four sites still pointing at the old / + query-string destinations:
- WelcomeStep3 `completeWizardAndExit` (Send invites)
- WelcomeStep3 `handleSkipStep` (Skip)
- VerifyEmailPage post-verify auto-redirect (`setTimeout`)
- VerifyEmailPage success-state "Go to dashboard" Link
These all worked by accident because PublicLanding redirects authed
users from / to /home — so users still landed on the dashboard, but
through an unnecessary mount-and-redirect flicker, and the
`?welcome=true` / `?verified=1` query markers got dropped on the way.
Drop both query markers — neither is read anywhere in the codebase
(grepped frontend/src; the dashboard's onboarding UX is driven by
`getOnboardingStatus`, not URL state). Carrying dead URL params
just invites future "is this load-bearing?" investigations.
Test stubs in WelcomeStep3.test.tsx and VerifyEmailPage.test.tsx
moved from `` to `` so the
assertions verify the new destination instead of accidentally matching
the old one (the previous stubs masked the partial migration).
Out of scope: AcceptInvitePage and OAuthCallbackPage still use
`?welcome=teammate`, but that one carries an explicit "decoded by the
dashboard in Task 41" annotation and may be wired up later, so left
untouched.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
frontend/src/pages/VerifyEmailPage.tsx | 10 ++++------
frontend/src/pages/__tests__/VerifyEmailPage.test.tsx | 6 +++---
frontend/src/pages/welcome/WelcomeStep3.tsx | 6 +++---
.../src/pages/welcome/__tests__/WelcomeStep3.test.tsx | 2 +-
4 files changed, 11 insertions(+), 13 deletions(-)
diff --git a/frontend/src/pages/VerifyEmailPage.tsx b/frontend/src/pages/VerifyEmailPage.tsx
index 12fb49b8..0ac355a0 100644
--- a/frontend/src/pages/VerifyEmailPage.tsx
+++ b/frontend/src/pages/VerifyEmailPage.tsx
@@ -20,8 +20,7 @@ const SUCCESS_REDIRECT_MS = 1200
* "Already verified" state. No API call.
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
* React 19 strict-mode double-invoke from double-firing the call). On
- * success, refresh the auth store and bounce to `/?verified=1` so the
- * dashboard surfaces a toast.
+ * success, refresh the auth store and bounce to `/home`.
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
* `POST /auth/email/send-verification`.
*/
@@ -70,10 +69,9 @@ export function VerifyEmailPage() {
if (cancelled) return
setStatus('success')
toast.success('Email verified')
- // Brief success state, then redirect with a query flag so the
- // dashboard can re-surface confirmation if it wants to.
+ // Brief success state, then redirect to the dashboard.
window.setTimeout(() => {
- navigate('/?verified=1', { replace: true })
+ navigate('/home', { replace: true })
}, SUCCESS_REDIRECT_MS)
})
.catch((err) => {
@@ -126,7 +124,7 @@ export function VerifyEmailPage() {
Redirecting you to the dashboard…
} />
- dashboard} />
+ dashboard} />
,
@@ -130,7 +130,7 @@ describe('VerifyEmailPage', () => {
} />
- dashboard} />
+ dashboard} />
,
@@ -142,7 +142,7 @@ describe('VerifyEmailPage', () => {
} />
- dashboard} />
+ dashboard} />
,
diff --git a/frontend/src/pages/welcome/WelcomeStep3.tsx b/frontend/src/pages/welcome/WelcomeStep3.tsx
index b345dfa3..be2f6309 100644
--- a/frontend/src/pages/welcome/WelcomeStep3.tsx
+++ b/frontend/src/pages/welcome/WelcomeStep3.tsx
@@ -39,7 +39,7 @@ function makeEmptyRow(): InviteRow {
*
* 1. POST `/accounts/me/invites/bulk` with populated rows.
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
- * 3. Navigate to `/?welcome=true` and fire a "You're all set" toast.
+ * 3. Navigate to `/home` and fire a "You're all set" toast.
*
* Partial-failure UX: rows in `failed[]` keep their input and show an
* inline error. The wizard does NOT auto-advance when there are failures —
@@ -109,7 +109,7 @@ export function WelcomeStep3() {
await onboardingApi.updateStep({ step: 3, action: 'complete' })
await fetchUser()
toast.success("You're all set!")
- navigate('/?welcome=true')
+ navigate('/home')
}
const handleSendInvites = async () => {
@@ -177,7 +177,7 @@ export function WelcomeStep3() {
await onboardingApi.updateStep({ step: 3, action: 'skip' })
await fetchUser()
toast.success("You're all set!")
- navigate('/?welcome=true')
+ navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
index c90be2dd..b6d0788e 100644
--- a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
+++ b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
@@ -88,7 +88,7 @@ function renderPage() {
} />
- dashboard} />
+ dashboard} />
,
)
--
2.49.1
From 067574ad6acf0b83ac63c2bd06f8750793f3d3f8 Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Thu, 28 May 2026 14:54:30 -0400
Subject: [PATCH 5/9] feat(ai): robust response extraction + structured-output
foundation
Harden the Anthropic provider and lay the groundwork for schema-constrained
JSON, optimizing the existing claude-sonnet-4-6 / claude-haiku-4-5 usage
(no model changes).
ai_provider.py:
- _extract_text_from_response replaces fragile response.content[0].text:
skips non-text leading blocks (e.g. thinking), returns the first text
block, logs an anthropic.stop_reason warning on max_tokens/refusal
(truncation now observable), and raises ValueError on a no-text response.
- generate_json gains an optional `schema` param. Anthropic wires it to
output_config.format (structured outputs); schema=None preserves the exact
prior call for every existing caller. Gemini accepts-and-ignores it.
kb_conversion_service.py:
- TROUBLESHOOTING_SCHEMA / PROCEDURAL_SCHEMA + _schema_for_target_type(),
modelled as a strict superset of every field the prompts emit.
- convert_document passes the schema only when the new
AI_KB_CONVERT_STRUCTURED_OUTPUT setting is True (default False). The
_try_repair_json fallback stays as belt-and-suspenders.
Tests: 14 provider + 7 schema, TDD (red-green). Live constrained-decoding
smoke-test still required before enabling the flag in production.
Co-Authored-By: Claude Opus 4.7
---
backend/app/core/ai_provider.py | 72 ++++++-
backend/app/core/config.py | 6 +
backend/app/core/kb_conversion_service.py | 120 ++++++++++++
backend/tests/test_ai_provider.py | 209 ++++++++++++++++++++-
backend/tests/test_kb_conversion_schema.py | 104 ++++++++++
5 files changed, 503 insertions(+), 8 deletions(-)
create mode 100644 backend/tests/test_kb_conversion_schema.py
diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py
index 1e0112b2..fba3b505 100644
--- a/backend/app/core/ai_provider.py
+++ b/backend/app/core/ai_provider.py
@@ -147,6 +147,40 @@ def build_anthropic_chat_messages(
return messages
+def _extract_text_from_response(response: Any, model: str) -> str:
+ """Return the first text block's text from an Anthropic message response.
+
+ Robustness over the naive ``response.content[0].text``:
+ - Skips non-text leading blocks (e.g. ``thinking``) and returns the first
+ block whose ``type == "text"``. Indexing ``content[0]`` blindly throws or
+ returns garbage the moment a non-text block leads the response.
+ - Surfaces truncation/refusal: when ``stop_reason`` is ``max_tokens`` or
+ ``refusal``, emits a structured warning so silent output corruption
+ (truncated JSON, empty refusals) is observable rather than handed
+ downstream to be guessed at.
+ - Raises ``ValueError`` when no text block is present (e.g. a bare refusal)
+ instead of returning a non-text block's attributes.
+ """
+ stop_reason = getattr(response, "stop_reason", None)
+ if stop_reason in ("max_tokens", "refusal"):
+ logger.warning(
+ "anthropic.stop_reason",
+ extra={
+ "event": "anthropic.stop_reason",
+ "model": model,
+ "stop_reason": stop_reason,
+ },
+ )
+
+ for block in response.content:
+ if getattr(block, "type", None) == "text":
+ return block.text
+
+ raise ValueError(
+ f"Anthropic response contained no text block (stop_reason={stop_reason!r})"
+ )
+
+
def _log_anthropic_cache_usage(usage: Any, model: str) -> None:
"""Emit a structured log line capturing cache_read / cache_creation tokens."""
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
@@ -176,6 +210,7 @@ class AIProvider(ABC):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
+ schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
"""Generate a JSON response from the AI model.
@@ -185,6 +220,15 @@ class AIProvider(ABC):
Anthropic prompt caching per module-docstring policy.
messages: List of message dicts with "role" and "content" keys.
max_tokens: Maximum output tokens.
+ schema: Optional JSON Schema constraining the response shape.
+ When provided, the Anthropic backend uses structured outputs
+ (`output_config.format`) to guarantee valid, parseable JSON —
+ no markdown fences, no truncated-brace repair. Must satisfy the
+ structured-output schema limits (every object needs
+ `additionalProperties: false`; no recursion; numeric/string
+ constraints are stripped). `None` preserves the legacy
+ prompt-only behavior. The Gemini backend currently ignores this
+ argument (it already requests `application/json`).
Returns:
Tuple of (response_text, input_tokens, output_tokens).
@@ -231,7 +275,11 @@ class GeminiProvider(AIProvider):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
+ schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
+ # `schema` is accepted for interface parity but ignored: Gemini already
+ # constrains output via response_mime_type="application/json" below.
+ # Mapping JSON Schema -> Gemini response_schema is deferred.
from google import genai
from google.genai import types as genai_types
@@ -362,18 +410,28 @@ class AnthropicProvider(AIProvider):
system_prompt: str | list[SystemBlock],
messages: list[dict[str, Any]],
max_tokens: int = 4096,
+ schema: dict[str, Any] | None = None,
) -> tuple[str, int, int]:
client = _get_anthropic_client(self._api_key, self._timeout)
normalized_system = _normalize_system_for_anthropic(system_prompt)
- response = await client.messages.create(
- model=self._model,
- max_tokens=max_tokens,
- system=normalized_system,
- messages=messages,
- )
+ create_kwargs: dict[str, Any] = {
+ "model": self._model,
+ "max_tokens": max_tokens,
+ "system": normalized_system,
+ "messages": messages,
+ }
+ if schema is not None:
+ # Structured outputs: constrain the response to valid JSON matching
+ # the schema (Sonnet 4.6 / Haiku 4.5). Removes the need for
+ # markdown-fence stripping and truncated-JSON repair downstream.
+ create_kwargs["output_config"] = {
+ "format": {"type": "json_schema", "schema": schema}
+ }
- text = response.content[0].text
+ response = await client.messages.create(**create_kwargs)
+
+ text = _extract_text_from_response(response, self._model)
input_tokens = response.usage.input_tokens
output_tokens = response.usage.output_tokens
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index d1582265..5f215cda 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -155,6 +155,12 @@ class Settings(BaseSettings):
AI_CONVERSATION_TTL_HOURS: int = 24
AI_MAX_CALLS_PER_FLOW: int = 10
AI_REQUEST_TIMEOUT_SECONDS: int = 120
+ # When True, KB conversion constrains the Anthropic response with a JSON
+ # schema (structured outputs) instead of relying on prompt-only JSON +
+ # downstream fence-stripping / brace-repair. Default OFF: enable in staging
+ # and smoke-test constrained decoding against the live model before turning
+ # it on in production. Only affects the Anthropic backend.
+ AI_KB_CONVERT_STRUCTURED_OUTPUT: bool = False
# AI Provider selection
AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic"
GOOGLE_AI_API_KEY: Optional[str] = None
diff --git a/backend/app/core/kb_conversion_service.py b/backend/app/core/kb_conversion_service.py
index b5e7f636..1145a4f7 100644
--- a/backend/app/core/kb_conversion_service.py
+++ b/backend/app/core/kb_conversion_service.py
@@ -202,6 +202,115 @@ the engineer attached, NOT from this schema):
9. Return ONLY valid JSON — no markdown fences, no explanation text."""
+# ── Structured-output schemas ──
+#
+# These constrain the model's JSON via Anthropic structured outputs
+# (output_config.format) so the response is guaranteed valid and parseable —
+# no markdown fences, no truncated-brace repair. They must be a SUPERSET of
+# every field the corresponding system prompt instructs the model to emit:
+# additionalProperties is False everywhere, so any field the prompt asks for
+# but the schema omits would be impossible to produce.
+#
+# `type`/`field_type` are intentionally left as plain strings (no enum): the
+# downstream parser already normalizes/tolerates the type values, and an enum
+# risks constraining the model away from a value the prompt would yield.
+
+_TROUBLESHOOTING_OPTION_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "label": {"type": "string"},
+ "next_node_id": {"type": "string"},
+ },
+ "required": ["label", "next_node_id"],
+ "additionalProperties": False,
+}
+
+_TROUBLESHOOTING_NODE_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "type": {"type": "string"},
+ "question": {"type": "string"},
+ "options": {"type": "array", "items": _TROUBLESHOOTING_OPTION_SCHEMA},
+ "next_node_id": {"type": "string"},
+ "confidence": {"type": "number"},
+ "source_excerpt": {"type": "string"},
+ },
+ # Only the universal fields are required. `question`/`options`/`next_node_id`
+ # vary by node type and stay optional so a resolution node need not carry
+ # options and an action node need not carry a question.
+ "required": ["id", "type", "confidence", "source_excerpt"],
+ "additionalProperties": False,
+}
+
+TROUBLESHOOTING_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ "nodes": {"type": "array", "items": _TROUBLESHOOTING_NODE_SCHEMA},
+ },
+ "required": ["title", "description", "nodes"],
+ "additionalProperties": False,
+}
+
+_PROCEDURAL_STEP_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "type": {"type": "string"},
+ "content": {"type": "string"},
+ "confidence": {"type": "number"},
+ "source_excerpt": {"type": "string"},
+ },
+ "required": ["id", "type", "content", "confidence", "source_excerpt"],
+ "additionalProperties": False,
+}
+
+_PROCEDURAL_INTAKE_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "variable_name": {"type": "string"},
+ "label": {"type": "string"},
+ "field_type": {"type": "string"},
+ "required": {"type": "boolean"},
+ "display_order": {"type": "integer"},
+ },
+ "required": [
+ "variable_name",
+ "label",
+ "field_type",
+ "required",
+ "display_order",
+ ],
+ "additionalProperties": False,
+}
+
+PROCEDURAL_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ "steps": {"type": "array", "items": _PROCEDURAL_STEP_SCHEMA},
+ "intake_form": {"type": "array", "items": _PROCEDURAL_INTAKE_SCHEMA},
+ },
+ "required": ["title", "description", "steps", "intake_form"],
+ "additionalProperties": False,
+}
+
+
+def _schema_for_target_type(target_type: str) -> dict[str, Any]:
+ """Return the structured-output schema for a KB conversion target type.
+
+ Mirrors the prompt selection in ``convert_document``: only
+ ``"troubleshooting"`` uses the decision-tree schema; everything else is
+ treated as a procedural flow.
+ """
+ if target_type == "troubleshooting":
+ return TROUBLESHOOTING_SCHEMA
+ return PROCEDURAL_SCHEMA
+
+
def _build_user_message(
source_text: str,
source_metadata: dict[str, Any] | None,
@@ -404,6 +513,16 @@ async def convert_document(
model = settings.get_model_for_action("kb_convert")
provider = get_ai_provider(model=model)
+ # Structured outputs (flagged): constrain the response to a JSON schema so
+ # the model can't emit fences or truncated JSON. Falls back to prompt-only
+ # JSON (schema=None) when disabled; the parse path below stays intact either
+ # way as a belt-and-suspenders fallback.
+ schema = (
+ _schema_for_target_type(kb_import.target_type)
+ if settings.AI_KB_CONVERT_STRUCTURED_OUTPUT
+ else None
+ )
+
try:
raw_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=[
@@ -414,6 +533,7 @@ async def convert_document(
],
messages=[{"role": "user", "content": user_message}],
max_tokens=16384,
+ schema=schema,
)
except Exception as e:
logger.error("AI conversion failed for kb_import=%s: %s", kb_import.id, e)
diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py
index 611c8e7b..2e9d4368 100644
--- a/backend/tests/test_ai_provider.py
+++ b/backend/tests/test_ai_provider.py
@@ -96,7 +96,8 @@ class TestAnthropicProvider:
)
mock_response = MagicMock()
- mock_response.content = [MagicMock(text='{"result": "ok"}')]
+ mock_response.content = [MagicMock(type="text", text='{"result": "ok"}')]
+ mock_response.stop_reason = "end_turn"
mock_response.usage = MagicMock(input_tokens=100, output_tokens=50)
mock_client = AsyncMock()
@@ -120,6 +121,170 @@ class TestAnthropicProvider:
messages=[{"role": "user", "content": "Hello"}],
)
+ @pytest.mark.asyncio
+ async def test_generate_json_skips_non_text_blocks(self):
+ """A leading non-text block (e.g. thinking) is skipped; the first
+ text block's text is returned instead of content[0].text."""
+ from app.core import ai_provider
+
+ ai_provider._anthropic_clients.clear()
+
+ provider = AnthropicProvider(
+ api_key="skip-key", model="claude-sonnet-4-6", timeout=31
+ )
+
+ thinking_block = MagicMock(type="thinking", thinking="hmm...")
+ text_block = MagicMock(type="text", text='{"ok": 1}')
+ mock_response = MagicMock()
+ mock_response.content = [thinking_block, text_block]
+ mock_response.stop_reason = "end_turn"
+ mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
+
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+
+ with patch("anthropic.AsyncAnthropic", return_value=mock_client):
+ text, _, _ = await provider.generate_json(
+ system_prompt="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ )
+
+ assert text == '{"ok": 1}'
+
+ @pytest.mark.asyncio
+ async def test_generate_json_raises_when_no_text_block(self):
+ """A response with no text block (e.g. a bare refusal) raises a clear
+ error instead of returning a non-text block's attributes."""
+ from app.core import ai_provider
+
+ ai_provider._anthropic_clients.clear()
+
+ provider = AnthropicProvider(
+ api_key="empty-key", model="claude-sonnet-4-6", timeout=32
+ )
+
+ mock_response = MagicMock()
+ mock_response.content = [MagicMock(type="thinking", thinking="...")]
+ mock_response.stop_reason = "refusal"
+ mock_response.usage = MagicMock(input_tokens=10, output_tokens=0)
+
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+
+ with patch("anthropic.AsyncAnthropic", return_value=mock_client):
+ with pytest.raises(ValueError, match="no text block"):
+ await provider.generate_json(
+ system_prompt="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ )
+
+ @pytest.mark.asyncio
+ async def test_generate_json_logs_warning_on_truncation(self, caplog):
+ """When stop_reason is max_tokens, a warning is logged (truncation
+ signal) and the partial text is still returned."""
+ import logging
+
+ from app.core import ai_provider
+
+ ai_provider._anthropic_clients.clear()
+
+ provider = AnthropicProvider(
+ api_key="trunc-key", model="claude-sonnet-4-6", timeout=33
+ )
+
+ text_block = MagicMock(type="text", text='{"partial": tr')
+ mock_response = MagicMock()
+ mock_response.content = [text_block]
+ mock_response.stop_reason = "max_tokens"
+ mock_response.usage = MagicMock(input_tokens=10, output_tokens=4096)
+
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+
+ with patch("anthropic.AsyncAnthropic", return_value=mock_client):
+ with caplog.at_level(logging.WARNING, logger="app.core.ai_provider"):
+ text, _, _ = await provider.generate_json(
+ system_prompt="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ )
+
+ assert text == '{"partial": tr'
+ truncation_records = [
+ r for r in caplog.records if getattr(r, "stop_reason", None) == "max_tokens"
+ ]
+ assert truncation_records, "expected a warning record for max_tokens truncation"
+
+ @pytest.mark.asyncio
+ async def test_generate_json_passes_output_config_when_schema_given(self):
+ """When a JSON schema is supplied, it is forwarded as
+ output_config.format so the API constrains the response shape."""
+ from app.core import ai_provider
+
+ ai_provider._anthropic_clients.clear()
+
+ provider = AnthropicProvider(
+ api_key="schema-key", model="claude-sonnet-4-6", timeout=34
+ )
+
+ mock_response = MagicMock()
+ mock_response.content = [MagicMock(type="text", text='{"title": "x"}')]
+ mock_response.stop_reason = "end_turn"
+ mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
+
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+
+ schema = {
+ "type": "object",
+ "properties": {"title": {"type": "string"}},
+ "required": ["title"],
+ "additionalProperties": False,
+ }
+
+ with patch("anthropic.AsyncAnthropic", return_value=mock_client):
+ await provider.generate_json(
+ system_prompt="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ max_tokens=512,
+ schema=schema,
+ )
+
+ mock_client.messages.create.assert_called_once_with(
+ model="claude-sonnet-4-6",
+ max_tokens=512,
+ system="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ output_config={"format": {"type": "json_schema", "schema": schema}},
+ )
+
+ @pytest.mark.asyncio
+ async def test_generate_json_no_output_config_when_schema_none(self):
+ """With no schema, output_config is not sent (backward compatible)."""
+ from app.core import ai_provider
+
+ ai_provider._anthropic_clients.clear()
+
+ provider = AnthropicProvider(
+ api_key="noschema-key", model="claude-sonnet-4-6", timeout=35
+ )
+
+ mock_response = MagicMock()
+ mock_response.content = [MagicMock(type="text", text="{}")]
+ mock_response.stop_reason = "end_turn"
+ mock_response.usage = MagicMock(input_tokens=1, output_tokens=1)
+
+ mock_client = AsyncMock()
+ mock_client.messages.create = AsyncMock(return_value=mock_response)
+
+ with patch("anthropic.AsyncAnthropic", return_value=mock_client):
+ await provider.generate_json(
+ system_prompt="You are a helper.",
+ messages=[{"role": "user", "content": "Hi"}],
+ )
+
+ _, call_kwargs = mock_client.messages.create.call_args
+ assert "output_config" not in call_kwargs
+
class TestGeminiProvider:
"""Tests for GeminiProvider.generate_json."""
@@ -174,6 +339,48 @@ class TestGeminiProvider:
mock_client.aio.models.generate_content.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_generate_json_accepts_and_ignores_schema(self):
+ """Gemini accepts the schema kwarg (interface parity) and still
+ returns JSON; it does not error on the param."""
+ provider = GeminiProvider(api_key="test-key", model="gemini-2.5-flash")
+
+ mock_usage = MagicMock()
+ mock_usage.prompt_token_count = 5
+ mock_usage.candidates_token_count = 3
+
+ mock_response = MagicMock()
+ mock_response.text = '{"answer": 1}'
+ mock_response.usage_metadata = mock_usage
+
+ mock_client = MagicMock()
+ mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response)
+
+ mock_genai_module = MagicMock()
+ mock_genai_module.Client.return_value = mock_client
+
+ mock_types = MagicMock()
+ mock_types.Content.side_effect = lambda **kw: kw
+ mock_types.Part.side_effect = lambda **kw: kw
+ mock_types.GenerateContentConfig.side_effect = lambda **kw: kw
+
+ mock_google = MagicMock()
+ mock_google.genai = mock_genai_module
+ mock_genai_module.types = mock_types
+
+ with patch.dict(sys.modules, {
+ "google": mock_google,
+ "google.genai": mock_genai_module,
+ "google.genai.types": mock_types,
+ }):
+ text, _, _ = await provider.generate_json(
+ system_prompt="Generate JSON.",
+ messages=[{"role": "user", "content": "data"}],
+ schema={"type": "object"},
+ )
+
+ assert text == '{"answer": 1}'
+
@pytest.mark.asyncio
async def test_generate_json_handles_none_usage(self):
"""Token counts default to 0 when usage_metadata attributes are None."""
diff --git a/backend/tests/test_kb_conversion_schema.py b/backend/tests/test_kb_conversion_schema.py
new file mode 100644
index 00000000..fae4da3f
--- /dev/null
+++ b/backend/tests/test_kb_conversion_schema.py
@@ -0,0 +1,104 @@
+"""Tests for the structured-output JSON schemas used by KB conversion.
+
+These validate that the schemas are well-formed against the Anthropic
+structured-output limits (every object carries additionalProperties: false,
+`required` is a subset of declared properties, no numeric/length constraints)
+and that the target_type -> schema selector returns the right shape. They do
+NOT exercise the live API — constrained decoding must be smoke-tested against
+a real model before AI_KB_CONVERT_STRUCTURED_OUTPUT is enabled in production.
+"""
+
+from app.core.kb_conversion_service import (
+ PROCEDURAL_SCHEMA,
+ TROUBLESHOOTING_SCHEMA,
+ _schema_for_target_type,
+)
+
+# Constraints disallowed by Anthropic structured outputs (must be absent so the
+# API does not reject the schema or silently strip them).
+_DISALLOWED_KEYS = {
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "minLength",
+ "maxLength",
+ "minItems",
+ "maxItems",
+}
+
+
+def _assert_well_formed(schema: dict) -> None:
+ """Recursively assert a JSON schema obeys the structured-output limits."""
+ if schema.get("type") == "object":
+ assert schema.get("additionalProperties") is False, (
+ f"object schema missing additionalProperties: false: {schema}"
+ )
+ props = schema.get("properties", {})
+ required = set(schema.get("required", []))
+ assert required <= set(props), (
+ f"required keys not all declared as properties: {required - set(props)}"
+ )
+ for sub in props.values():
+ _assert_well_formed(sub)
+ elif schema.get("type") == "array":
+ _assert_well_formed(schema["items"])
+
+ assert not (_DISALLOWED_KEYS & set(schema)), (
+ f"schema uses unsupported constraint(s): {_DISALLOWED_KEYS & set(schema)}"
+ )
+
+
+class TestStructuredOutputSchemas:
+ def test_troubleshooting_schema_is_well_formed(self):
+ _assert_well_formed(TROUBLESHOOTING_SCHEMA)
+
+ def test_procedural_schema_is_well_formed(self):
+ _assert_well_formed(PROCEDURAL_SCHEMA)
+
+ def test_troubleshooting_schema_top_level_shape(self):
+ props = TROUBLESHOOTING_SCHEMA["properties"]
+ assert set(props) >= {"title", "description", "nodes"}
+ node = props["nodes"]["items"]
+ # Every field the troubleshooting prompt may emit must be modelled,
+ # else additionalProperties: false makes them impossible to produce.
+ assert set(node["properties"]) >= {
+ "id",
+ "type",
+ "question",
+ "options",
+ "next_node_id",
+ "confidence",
+ "source_excerpt",
+ }
+
+ def test_procedural_schema_top_level_shape(self):
+ props = PROCEDURAL_SCHEMA["properties"]
+ assert set(props) >= {"title", "description", "steps", "intake_form"}
+ step = props["steps"]["items"]
+ assert set(step["properties"]) >= {
+ "id",
+ "type",
+ "content",
+ "confidence",
+ "source_excerpt",
+ }
+ intake = props["intake_form"]["items"]
+ assert set(intake["properties"]) >= {
+ "variable_name",
+ "label",
+ "field_type",
+ "required",
+ "display_order",
+ }
+
+
+class TestSchemaSelector:
+ def test_returns_troubleshooting_schema(self):
+ assert _schema_for_target_type("troubleshooting") is TROUBLESHOOTING_SCHEMA
+
+ def test_returns_procedural_schema_for_procedural(self):
+ assert _schema_for_target_type("procedural") is PROCEDURAL_SCHEMA
+
+ def test_defaults_to_procedural_for_unknown(self):
+ # convert_document treats any non-"troubleshooting" target as procedural.
+ assert _schema_for_target_type("something-else") is PROCEDURAL_SCHEMA
--
2.49.1
From 3fde3369c8070b45e916c570aea249bebfd36108 Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Thu, 28 May 2026 14:54:36 -0400
Subject: [PATCH 6/9] chore: gitignore core dumps (core.)
Stop crashed-process core dumps (core.144926, etc.) from showing up as
untracked noise / being committed by accident.
Co-Authored-By: Claude Opus 4.7
---
.gitignore | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.gitignore b/.gitignore
index 63631b0e..b6c612a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -237,6 +237,10 @@ package.json
package-lock.json
.worktrees/
.gstack/
+
+# Core dumps from crashed processes (e.g. core.12345)
+core.[0-9]*
+**/core.[0-9]*
.gitnexus
# graphify knowledge graph outputs
--
2.49.1
From 60b1e654f850bf349543d6196a1e8593644b5bad Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Thu, 28 May 2026 14:48:18 -0400
Subject: [PATCH 7/9] feat(landing): redesign hero + editorial layout with
Atkinson Hyperlegible
Recover and commit the landing-page redesign that had been sitting
uncommitted in the working tree: refreshed dark palette (adjusted
--lp-bg-alt, electric-blue accent), Atkinson Hyperlegible Next display
+ body type, and editorial hero/section layout in LandingPage.tsx, with
the matching font preload in index.html.
Co-Authored-By: Claude Opus 4.7
---
frontend/index.html | 2 +-
frontend/src/pages/LandingPage.tsx | 170 +++++----
frontend/src/styles/landing.css | 561 ++++++++++++++++++-----------
3 files changed, 434 insertions(+), 299 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 6e98ac5c..b821fef3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -10,7 +10,7 @@
-
+
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index 3c352033..3a8c4610 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -164,46 +164,74 @@ export default function LandingPage() {
- {/* Problem — asymmetric: headline left, cards right */}
-
+ {/* Problem — editorial list, no cards */}
+
The Problem
Documentation is broken. Everyone knows it.
-
Engineers don't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch — every time.
-
-
-
-
-
-
+
Engineers don't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch, every time.
+
+
+ 01
+
+
15–25 min lost per ticket
+
More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does.
+
+
+
+ 02
+
+
Vague, useless notes
+
“Fixed Outlook” tells no one anything. Notes under pressure are always too vague to help next time.
+
+
+
+ 03
+
+
Knowledge walks out the door
+
When a senior engineer leaves, years of tribal knowledge vanish overnight.
+
+
+
+ 04
+
+
Context switching kills speed
+
Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus.
- What if documentation was a byproduct of solving the issue — not a separate task?
+ What if documentation was a byproduct of solving the issue, not a separate task?
{/* How It Works — zigzag */}
-
+
How It Works
Three steps. Zero note-writing.
@@ -268,54 +296,47 @@ export default function LandingPage() {
- {/* Features */}
-
+ {/* Features — editorial spec list */}
+
Features
Everything you need to troubleshoot faster.
-
-
-
+
FP
-
FlowPilot — Your AI Copilot
-
Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself — as a byproduct of solving the problem.
+
FlowPilot, your AI copilot
+
Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself, as a byproduct of solving the problem.
-
- }
- title="Guided Flows"
- description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency."
- />
- }
- title="Zero Empty Tickets"
- description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures."
- />
- }
- title="Team Knowledge"
- description="Solutions are saved and surfaced when the next engineer hits a similar issue."
- />
- }
- title="Session Analytics"
- description="Track resolution times, identify recurring issues, and measure team performance."
- />
- }
- title="PSA Integration"
- description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets."
- />
-
+
+
+
Guided Flows
+
Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency.
+
+
+
Zero Empty Tickets
+
Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures.
+
+
+
Team Knowledge
+
Solutions are saved and surfaced when the next engineer hits a similar issue.
+
+
+
Session Analytics
+
Track resolution times, identify recurring issues, and measure team performance.
+
+
+
PSA Integration
+
Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets.