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
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:
parent
fceaaf9a2c
commit
4adfef2ad0
4 changed files with 744 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
530
frontend/src/components/site-finder/ChatPanel.tsx
Normal file
530
frontend/src/components/site-finder/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/site-finder/__tests__/ChatPanel.test.tsx
Normal file
149
frontend/src/components/site-finder/__tests__/ChatPanel.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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) ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue