feat(site-finder): grounded parcel-chat panel UI (#958)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 45s
CI / frontend-tests (pull_request) Successful in 42s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m4s
CI / changes (pull_request) Successful in 6s

Chat panel on the analysis screen consuming POST /api/v1/chat/ask:
preset-intent quick-buttons (summary/what_to_build/why_forecast/risks/
scenarios), history-aware turns (cap 10), advisory badge + grounded_in
provenance + llm_used marker. Plain-text answer rendering (no HTML
injection); loading/pending/error states with retry. TanStack useMutation
(no useEffect-for-HTTP), tokens-only, Lucide icons, TS strict. Types inline
(codegen backend unreachable in env); no new dependency.

Refs #958
This commit is contained in:
Light1YT 2026-06-08 18:08:01 +05:00
parent fceaaf9a2c
commit 4adfef2ad0
4 changed files with 744 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { ChatPanel } from "@/components/site-finder/ChatPanel";
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
@ -173,6 +174,9 @@ export function AnalysisPageContent({ cad }: Props) {
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} selectedHorizon={horizon} />
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
<ChatPanel cadNum={cad} />
</div>
</div>
</main>

View file

@ -0,0 +1,530 @@
"use client";
/**
* ChatPanel grounded parcel-chat for the Site Finder analysis screen (#958).
*
* Consumes POST /api/v1/chat/ask. The thread (user/assistant turns) lives in
* component state; the most recent turns are passed back as `history` (capped at
* MAX_HISTORY_TURNS) so the backend keeps context. Five preset-intent quick
* buttons send a canned question with the matching `intent`.
*
* Send is an ACTION driven by a TanStack `useMutation` (no useEffect for HTTP,
* per frontend.md). The backend answer is PLAIN TEXT and is rendered as text
* with preserved whitespace never injected as HTML (XSS rule).
*
* States:
* sending input disabled, "Отправляем…" affordance
* report_status==="pending" backend's "run analysis first" message, shown as
* a neutral assistant turn (not an error)
* network error inline error row with a retry button (re-runs the last ask)
*
* Each assistant answer surfaces: an advisory <Badge> (advisory===true), a
* grounded_in provenance line ("источник: разделы X · run #N"), and a subtle
* "детерминированный ответ" marker when llm_used===false.
*/
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { MessageSquare, Send, AlertTriangle } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { postChatAsk } from "@/lib/site-finder-api";
import type {
ChatAskRequest,
ChatAskResponse,
ChatIntent,
ChatMessage,
} from "@/lib/site-finder-api";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
cadNum: string;
}
// ── Local thread model ─────────────────────────────────────────────────────────
interface AssistantMeta {
advisory: boolean;
llmUsed: boolean;
reportStatus: ChatAskResponse["report_status"];
groundedIn: ChatAskResponse["grounded_in"];
}
interface ThreadTurn {
id: number;
role: "user" | "assistant";
content: string;
/** Present only on assistant turns. */
meta?: AssistantMeta;
}
// ── Constants ──────────────────────────────────────────────────────────────────
/** Recent turns sent back as context (cap ~10, per the issue). */
const MAX_HISTORY_TURNS = 10;
const SCROLL_MARGIN = 72;
const PRESETS: { intent: ChatIntent; label: string; message: string }[] = [
{
intent: "summary",
label: "Сводка",
message: "Дай краткую сводку по участку.",
},
{
intent: "what_to_build",
label: "Что строить",
message: "Что выгодно построить на этом участке?",
},
{
intent: "why_forecast",
label: "Почему такой прогноз",
message: "Почему прогноз именно такой?",
},
{ intent: "risks", label: "Риски", message: "Какие риски у этого участка?" },
{
intent: "scenarios",
label: "Сценарии",
message: "Какие сценарии развития возможны?",
},
];
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Map backend section codes to the provenance line, e.g. "разделы 6.1, 6.4". */
function provenanceLine(meta: AssistantMeta): string | null {
const g = meta.groundedIn;
if (!g) return null;
const parts: string[] = [];
if (g.sections.length > 0) {
parts.push(`разделы ${g.sections.join(", ")}`);
}
parts.push(`run #${g.run_id.toLocaleString("ru")}`);
return `источник: ${parts.join(" · ")}`;
}
// ── ChatPanel ──────────────────────────────────────────────────────────────────
export function ChatPanel({ cadNum }: Props) {
const [thread, setThread] = useState<ThreadTurn[]>([]);
const [input, setInput] = useState("");
// Last request kept so a failed send can be retried verbatim.
const lastRequestRef = useRef<ChatAskRequest | null>(null);
const turnIdRef = useRef(0);
const nextTurnId = () => {
turnIdRef.current += 1;
return turnIdRef.current;
};
const mutation = useMutation<ChatAskResponse, Error, ChatAskRequest>({
mutationFn: postChatAsk,
onSuccess: (res) => {
setThread((prev) => [
...prev,
{
id: nextTurnId(),
role: "assistant",
content: res.answer,
meta: {
advisory: res.advisory,
llmUsed: res.llm_used,
reportStatus: res.report_status,
groundedIn: res.grounded_in,
},
},
]);
},
});
/** Build history from the thread (recent turns only). */
function buildHistory(): ChatMessage[] {
return thread
.slice(-MAX_HISTORY_TURNS)
.map((t) => ({ role: t.role, content: t.content }));
}
/** Append a user turn and fire the ask. */
function send(message: string, intent?: ChatIntent) {
const trimmed = message.trim();
if (trimmed === "" || mutation.isPending) return;
const history = buildHistory();
setThread((prev) => [
...prev,
{ id: nextTurnId(), role: "user", content: trimmed },
]);
const req: ChatAskRequest = {
cad_num: cadNum,
message: trimmed,
...(intent ? { intent } : {}),
...(history.length > 0 ? { history } : {}),
};
lastRequestRef.current = req;
mutation.mutate(req);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const value = input;
setInput("");
send(value);
}
function handleRetry() {
const req = lastRequestRef.current;
if (req) mutation.mutate(req);
}
const isEmpty = thread.length === 0;
return (
<section
id="chat-panel"
aria-label="Чат по участку"
style={{
scrollMarginTop: SCROLL_MARGIN,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
}}
>
{/* Header */}
<header style={{ marginBottom: 12 }}>
<h2
style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<MessageSquare size={20} strokeWidth={1.5} aria-hidden />
Чат по участку
</h2>
<p
style={{
margin: "4px 0 0",
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Вопросы по анализу участка. Ответы опираются на рассчитанный прогноз.
</p>
</header>
{/* Preset-intent quick buttons */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginBottom: 16,
}}
>
{PRESETS.map((p) => (
<button
key={p.intent}
type="button"
onClick={() => send(p.message, p.intent)}
disabled={mutation.isPending}
style={{
padding: "6px 12px",
fontSize: 13,
fontWeight: 500,
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid transparent",
borderRadius: 6,
cursor: mutation.isPending ? "default" : "pointer",
opacity: mutation.isPending ? 0.6 : 1,
}}
>
{p.label}
</button>
))}
</div>
{/* Message thread */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
marginBottom: 16,
}}
>
{isEmpty && !mutation.isPending && (
<p
style={{
margin: 0,
padding: "16px 0",
fontSize: 13,
color: "var(--fg-tertiary)",
}}
>
Задайте вопрос или выберите тему выше.
</p>
)}
{thread.map((turn) =>
turn.role === "user" ? (
<UserBubble key={turn.id} content={turn.content} />
) : (
<AssistantBubble
key={turn.id}
content={turn.content}
meta={turn.meta}
/>
),
)}
{mutation.isPending && (
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--fg-tertiary)",
}}
>
Отправляем
</p>
)}
{mutation.isError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 12,
fontSize: 13,
color: "var(--danger)",
}}
>
<span
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden />
Не удалось отправить запрос. Проверьте соединение.
</span>
<button
type="button"
onClick={handleRetry}
style={{
padding: "4px 12px",
fontSize: 13,
fontWeight: 500,
color: "var(--danger)",
background: "var(--bg-card)",
border: "1px solid var(--danger)",
borderRadius: 6,
cursor: "pointer",
}}
>
Повторить
</button>
</div>
)}
</div>
{/* Composer */}
<form
onSubmit={handleSubmit}
style={{ display: "flex", alignItems: "flex-end", gap: 8 }}
>
<label htmlFor="chat-input" style={visuallyHidden}>
Сообщение
</label>
<textarea
id="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const value = input;
setInput("");
send(value);
}
}}
rows={2}
placeholder="Спросите про участок…"
disabled={mutation.isPending}
style={{
flex: 1,
resize: "vertical",
minHeight: 44,
padding: "8px 12px",
fontSize: 14,
lineHeight: 1.4,
color: "var(--fg-primary)",
background: "var(--bg-card)",
border: "1px solid var(--border-strong)",
borderRadius: 8,
fontFamily: "inherit",
}}
/>
<button
type="submit"
aria-label="Отправить сообщение"
disabled={mutation.isPending || input.trim() === ""}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
color: "var(--fg-on-dark)",
background: "var(--accent)",
border: "1px solid transparent",
borderRadius: 8,
cursor:
mutation.isPending || input.trim() === "" ? "default" : "pointer",
opacity: mutation.isPending || input.trim() === "" ? 0.6 : 1,
whiteSpace: "nowrap",
}}
>
<Send size={16} strokeWidth={1.5} aria-hidden />
Отправить
</button>
</form>
</section>
);
}
// ── Bubbles ────────────────────────────────────────────────────────────────────
const visuallyHidden: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
};
function UserBubble({ content }: { content: string }) {
return (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
style={{
maxWidth: "85%",
padding: "10px 14px",
background: "var(--accent-soft)",
border: "1px solid transparent",
borderRadius: 12,
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
// Plain text: preserve the user's line breaks without HTML injection.
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{content}
</div>
</div>
);
}
function AssistantBubble({
content,
meta,
}: {
content: string;
meta?: AssistantMeta;
}) {
const isPending = meta?.reportStatus === "pending";
const provenance = meta ? provenanceLine(meta) : null;
return (
<div style={{ display: "flex", justifyContent: "flex-start" }}>
<div
style={{
maxWidth: "85%",
padding: "10px 14px",
background: "var(--bg-card-alt)",
border: `1px solid ${
isPending ? "var(--warn)" : "var(--border-card)"
}`,
borderRadius: 12,
}}
>
{/* Badge row */}
{meta && (meta.advisory || isPending || !meta.llmUsed) && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
}}
>
{isPending && (
<Badge variant="warning" size="sm">
Прогноз не рассчитан
</Badge>
)}
{meta.advisory && (
<Badge
variant="warning"
size="sm"
icon={<AlertTriangle size={12} strokeWidth={1.5} />}
>
Advisory
</Badge>
)}
{!meta.llmUsed && (
<Badge variant="neutral" size="sm">
Детерминированный ответ
</Badge>
)}
</div>
)}
{/* Answer — PLAIN TEXT (backend returns plain text; no HTML injection). */}
<div
style={{
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{content}
</div>
{/* grounded_in provenance line */}
{provenance && (
<p
style={{
margin: "8px 0 0",
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
fontVariantNumeric: "tabular-nums",
}}
>
{provenance}
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,149 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatPanel } from "../ChatPanel";
import type { ChatAskRequest, ChatAskResponse } from "@/lib/site-finder-api";
// Mock the API client so the mutation resolves with a controlled response and we
// can assert the request payload (intent / cad_num).
const postChatAsk = vi.fn<(req: ChatAskRequest) => Promise<ChatAskResponse>>();
vi.mock("@/lib/site-finder-api", () => ({
postChatAsk: (req: ChatAskRequest) => postChatAsk(req),
}));
function renderPanel(cadNum = "66:41:0000000:1") {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ChatPanel cadNum={cadNum} />
</QueryClientProvider>,
);
}
const READY_RESPONSE: ChatAskResponse = {
answer: "Краткая сводка по участку.",
grounded_in: { run_id: 42, schema_version: "1.0", sections: ["6.1", "6.4"] },
llm_used: true,
fallback_reason: null,
advisory: true,
report_status: "ready",
};
const PENDING_RESPONSE: ChatAskResponse = {
answer: "Сначала запустите анализ участка.",
grounded_in: null,
llm_used: false,
fallback_reason: "report_pending",
advisory: false,
report_status: "pending",
};
beforeEach(() => {
postChatAsk.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("ChatPanel", () => {
it("renders the heading and the five preset-intent buttons", () => {
renderPanel();
expect(
screen.getByRole("heading", { name: "Чат по участку" }),
).toBeInTheDocument();
for (const label of [
"Сводка",
"Что строить",
"Почему такой прогноз",
"Риски",
"Сценарии",
]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
}
});
it("sends with the matching intent + cad_num when a preset is clicked", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel("66:41:0000000:1");
await userEvent.click(screen.getByRole("button", { name: "Риски" }));
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(1));
const req = postChatAsk.mock.calls[0][0];
expect(req.intent).toBe("risks");
expect(req.cad_num).toBe("66:41:0000000:1");
expect(req.message.length).toBeGreaterThan(0);
});
it("renders the assistant answer, advisory badge and provenance line", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(
await screen.findByText("Краткая сводка по участку."),
).toBeInTheDocument();
expect(screen.getByText("Advisory")).toBeInTheDocument();
// grounded_in → "источник: разделы 6.1, 6.4 · run #42"
expect(screen.getByText(/источник:/)).toHaveTextContent("разделы 6.1, 6.4");
expect(screen.getByText(/источник:/)).toHaveTextContent("run #42");
});
it("renders the pending state as a neutral message (not an error)", async () => {
postChatAsk.mockResolvedValue(PENDING_RESPONSE);
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(
await screen.findByText("Сначала запустите анализ участка."),
).toBeInTheDocument();
expect(screen.getByText("Прогноз не рассчитан")).toBeInTheDocument();
expect(screen.getByText("Детерминированный ответ")).toBeInTheDocument();
// pending is NOT an error → no alert / retry button
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
});
it("shows a retry affordance on network error", async () => {
postChatAsk.mockRejectedValueOnce(new Error("network down"));
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Повторить" });
expect(retry).toBeInTheDocument();
// Retry re-fires the same request.
postChatAsk.mockResolvedValueOnce(READY_RESPONSE);
await userEvent.click(retry);
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(2));
});
it("submits the free-text input and clears it", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel();
const input = screen.getByPlaceholderText("Спросите про участок…");
await userEvent.type(input, "Какова плотность застройки?");
await userEvent.click(
screen.getByRole("button", { name: "Отправить сообщение" }),
);
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(1));
expect(postChatAsk.mock.calls[0][0].message).toBe(
"Какова плотность застройки?",
);
expect(input).toHaveValue("");
});
});

View file

@ -487,6 +487,67 @@ export function useParcelPoiScoreQuery(cad: string) {
});
}
// ── Chat: POST /api/v1/chat/ask (#958) ───────────────────────────────────────
/**
* Parcel-chat contract (backend shipped, deployed). The endpoint is behind
* Caddy auth + RBAC (any authenticated user). Types are defined inline rather
* than via `npm run codegen` because codegen targets a live OpenAPI on
* localhost:8000 (`src/lib/api-types.ts`) which is unreachable in this env;
* these mirror the documented response exactly.
*/
export type ChatIntent =
| "summary"
| "what_to_build"
| "why_forecast"
| "risks"
| "scenarios"
| "unknown";
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}
export interface ChatAskRequest {
cad_num: string;
message: string;
intent?: ChatIntent;
/** Recent turns for context — caller caps the history length. */
history?: ChatMessage[];
}
export interface ChatGroundedIn {
run_id: number;
schema_version: string;
sections: string[];
}
/** "ready" → forecast exists; "pending" → answer is a "run analysis first" msg. */
export type ChatReportStatus = "ready" | "pending";
export interface ChatAskResponse {
answer: string;
grounded_in: ChatGroundedIn | null;
llm_used: boolean;
fallback_reason: string | null;
advisory: boolean;
report_status: ChatReportStatus;
}
/**
* POST /api/v1/chat/ask ask a grounded question about a parcel's analysis.
* Action (not a query) drive from a TanStack `useMutation` in the component.
* Uses the shared apiFetch (base URL + session header + Content-Type) per the
* "don't duplicate fetch config" rule.
*/
export function postChatAsk(req: ChatAskRequest): Promise<ChatAskResponse> {
return apiFetch<ChatAskResponse>("/api/v1/chat/ask", {
method: "POST",
body: JSON.stringify(req),
});
}
// ── Hook: useParcelForecastQuery (958-B3 / §22) ──────────────────────────────
/**