@@ -1103,27 +1265,17 @@ export function FloatingQAChat() {
{modeForItem === "negative" && (
-
哪里有问题?(可多选)
-
- {ERROR_OPTIONS.map((option) => {
- const checked = selectedErrors.includes(option);
- return (
-
- );
- })}
-
+
哪里有问题?(选填,逗号分隔)
+
+ setErrorTypes((prev) => ({
+ ...prev,
+ [msg.id]: parseFeedbackTags(e.target.value),
+ }))
+ }
+ />
+ {showPlanModeCard && (
+
+
+
+
当前模式
+
{planMode ? "需求模式" : "普通执行模式"}
+
+ {planStatus ?
{planStatus} : null}
+
+ {planTitle ? (
+
+ ) : null}
+ {planGoal ? (
+
+ ) : null}
+ {planMissingInfo.length > 0 ? (
+
+
待补信息
+
+ {planMissingInfo.map((field) => (
+
+ {field}
+
+ ))}
+
+
+ ) : null}
+ {planQuestionRound || planLastRoundField ? (
+
+ {planQuestionRound ? 当前轮次:第 {planQuestionRound} 轮 : null}
+ {planQuestionRound && planLastRoundField ? · : null}
+ {planLastRoundField ? 最近收敛字段:{planLastRoundField} : null}
+
+ ) : null}
+
+ )}
+
{implementationModules.length > 0 && (
实现模块(闭环提炼)
diff --git a/frontend/src/pages/QueryRouteGovernancePage.tsx b/frontend/src/pages/QueryRouteGovernancePage.tsx
new file mode 100644
index 00000000..290cbed4
--- /dev/null
+++ b/frontend/src/pages/QueryRouteGovernancePage.tsx
@@ -0,0 +1,2 @@
+export { default } from "./query-route-governance/QueryRouteGovernancePage";
+
diff --git a/frontend/src/pages/ai-config/constants.ts b/frontend/src/pages/ai-config/constants.ts
index 11fc5afe..e7e54318 100644
--- a/frontend/src/pages/ai-config/constants.ts
+++ b/frontend/src/pages/ai-config/constants.ts
@@ -12,6 +12,7 @@ import {
Settings2,
ShieldCheck,
Sparkles,
+ Waypoints,
} from "lucide-react";
import type { ConfigStatus, IntegrationSource } from "./types";
@@ -44,6 +45,13 @@ export const GOVERNANCE_TOOLS = [
to: "/ai-config/contract-sync-board",
icon: ShieldCheck,
},
+ {
+ id: "query-route-governance",
+ name: "Route Context 治理页",
+ description: "集中排查误路由、澄清偏重和负反馈样本",
+ to: "/ai-config/query-route-governance",
+ icon: Waypoints,
+ },
{
id: "mcp-config",
name: "MCP 模板接入中心",
diff --git a/frontend/src/pages/project-board/ProjectBoardPage.tsx b/frontend/src/pages/project-board/ProjectBoardPage.tsx
index 7adfd279..6a98b66a 100644
--- a/frontend/src/pages/project-board/ProjectBoardPage.tsx
+++ b/frontend/src/pages/project-board/ProjectBoardPage.tsx
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelCapabilityMatrix } from "@/components/model-capability/ModelCapabilityMatrix";
import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel";
+import { buildAIStewardQAUrl } from "@/components/ai-steward/qaPageAnchor";
import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader";
import { SSTCStageNav } from "@/components/sstc/SSTCStageNav";
import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace";
@@ -528,7 +529,7 @@ export default function ProjectBoardPage() {
navigate("/ai-steward")}
+ onOpenAi={() => navigate(buildAIStewardQAUrl({ projectId: id }))}
onOpenSettings={() => setSettingsOpen(true)}
onOpenWorkloads={() => navigate("/workloads")}
onExport={() => {
diff --git a/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx b/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
new file mode 100644
index 00000000..f70e0b09
--- /dev/null
+++ b/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.test.tsx
@@ -0,0 +1,161 @@
+import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { queryRouteGovernanceApi } from "@/api/queryRouteGovernance";
+import { GOVERNANCE_TOOLS } from "@/pages/ai-config/constants";
+import QueryRouteGovernancePage from "@/pages/QueryRouteGovernancePage";
+import { router } from "@/router";
+import { useAuthStore } from "@/stores/auth";
+import { renderWithProviders } from "@/test/renderWithProviders";
+
+vi.mock("@/api/queryRouteGovernance", () => ({
+ queryRouteGovernanceApi: {
+ getSummary: vi.fn(),
+ listRecords: vi.fn(),
+ getDetail: vi.fn(),
+ },
+}));
+
+vi.mock("@/components/ai-config/FeatureIntroGuide", () => ({
+ FeatureIntroGuide: () => feature-guide
,
+}));
+
+describe("QueryRouteGovernancePage", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ beforeEach(() => {
+ localStorage.clear();
+ useAuthStore.setState({
+ token: "test-token",
+ user: {
+ id: "7",
+ username: "boss",
+ role: "ADMIN",
+ },
+ });
+
+ vi.mocked(queryRouteGovernanceApi.getSummary).mockResolvedValue({
+ window_days: 30,
+ project_id: null,
+ kpis: {
+ total_records: 4,
+ mismatch_count: 1,
+ clarify_count: 2,
+ negative_feedback_count: 1,
+ internal_count: 1,
+ external_count: 2,
+ uncertain_count: 1,
+ },
+ route_modes: [{ key: "external", label: "外部实时查询", count: 2 }],
+ decision_origins: [{ key: "reply_anchor", label: "reply_anchor", count: 2 }],
+ });
+ vi.mocked(queryRouteGovernanceApi.listRecords).mockResolvedValue({
+ items: [
+ {
+ id: 7,
+ qa_record_id: 70,
+ project_id: 17,
+ session_id: 12,
+ session_title: "Alpha 会话",
+ question_text: "这个项目怎么样?",
+ route_mode_predicted: "internal",
+ route_mode_effective: "external",
+ decision_origin: "reply_anchor",
+ reply_mode: "clarify",
+ feedback_rating: "negative",
+ feedback_helpfulness: "could_be_better",
+ is_route_mismatch: true,
+ is_clarify: true,
+ anchor_summary: "source_chat:pmflow-project-17",
+ answer_mode: "clarify",
+ grounding_status: "insufficient_evidence",
+ asked_via: "pmflow_manual",
+ created_at: "2026-04-17T12:00:00+08:00",
+ updated_at: "2026-04-17T12:00:00+08:00",
+ },
+ ],
+ total: 1,
+ limit: 50,
+ offset: 0,
+ });
+ vi.mocked(queryRouteGovernanceApi.getDetail).mockResolvedValue({
+ record: {
+ id: 7,
+ qa_record_id: 70,
+ project_id: 17,
+ session_id: 12,
+ session_title: "Alpha 会话",
+ question_text: "这个项目怎么样?",
+ route_mode_predicted: "internal",
+ route_mode_effective: "external",
+ decision_origin: "reply_anchor",
+ reply_mode: "clarify",
+ feedback_rating: "negative",
+ feedback_helpfulness: "could_be_better",
+ is_route_mismatch: true,
+ is_clarify: true,
+ anchor_summary: "source_chat:pmflow-project-17",
+ answer_mode: "clarify",
+ grounding_status: "insufficient_evidence",
+ asked_via: "pmflow_manual",
+ created_at: "2026-04-17T12:00:00+08:00",
+ updated_at: "2026-04-17T12:00:00+08:00",
+ },
+ qa_record: {
+ id: 70,
+ question: "这个项目怎么样?",
+ answer: "外部实时查询:证据不足。",
+ },
+ route_memory: {
+ reply_contract: { mode: "clarify" },
+ probe_snapshot: { contract: { source_policy: "uncertain" } },
+ query_profile: { subject_focus: "project" },
+ feedback_outcome: { rating: "negative" },
+ decision_origin: "reply_anchor",
+ },
+ });
+ });
+
+ it("exposes governance entry and route", () => {
+ expect(
+ GOVERNANCE_TOOLS.some(
+ (item) => item.id === "query-route-governance" && item.to === "/ai-config/query-route-governance",
+ ),
+ ).toBe(true);
+ const aiConfigRoute = router.routes.find((route) => route.path === "/");
+ const children = aiConfigRoute?.children || [];
+ expect(children.some((route) => route.path === "ai-config/query-route-governance")).toBe(true);
+ });
+
+ it("renders summary cards, records table, and detail dialog", async () => {
+ renderWithProviders();
+
+ expect(await screen.findByText("Route / Context 治理页")).not.toBeNull();
+ expect(screen.getByText("近窗样本")).not.toBeNull();
+ expect(screen.getByText("误路由样本")).not.toBeNull();
+ expect(await screen.findByText("这个项目怎么样?")).not.toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "查看详情" }));
+
+ expect(await screen.findByText("Route / Context 样本详情")).not.toBeNull();
+ await waitFor(() => expect(queryRouteGovernanceApi.getDetail).toHaveBeenCalledWith(7, { project_id: undefined }));
+ expect(screen.getByText(/subject_focus/)).not.toBeNull();
+ });
+
+ it("shows manager-only fallback for non-manager users", async () => {
+ useAuthStore.setState({
+ token: "test-token",
+ user: {
+ id: "9",
+ username: "member",
+ role: "MEMBER",
+ },
+ });
+
+ renderWithProviders();
+
+ expect(await screen.findByText(/仅 `BOSS \/ ADMIN` 可访问 route\/context 治理页/)).not.toBeNull();
+ });
+});
diff --git a/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx b/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx
new file mode 100644
index 00000000..0a1f9cb1
--- /dev/null
+++ b/frontend/src/pages/query-route-governance/QueryRouteGovernancePage.tsx
@@ -0,0 +1,180 @@
+import { startTransition, useDeferredValue, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Compass, ShieldCheck } from "lucide-react";
+import { useSearchParams } from "react-router-dom";
+
+import { queryRouteGovernanceApi, type QueryRouteGovernanceRecord } from "@/api/queryRouteGovernance";
+import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
+import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
+import { Card, CardContent } from "@/components/ui/card";
+import { isManagerRole } from "@/lib/rbac";
+import { useAuthStore } from "@/stores/auth";
+import { QueryRouteGovernanceDetailDialog } from "./components/QueryRouteGovernanceDetailDialog";
+import { QueryRouteGovernanceOverviewCards } from "./components/QueryRouteGovernanceOverviewCards";
+import { QueryRouteGovernanceTable } from "./components/QueryRouteGovernanceTable";
+
+function parseProjectId(value: string | null): number | undefined {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
+ return Math.trunc(parsed);
+}
+
+export default function QueryRouteGovernancePage() {
+ const user = useAuthStore((state) => state.user);
+ const canOperate = isManagerRole(user?.role);
+ const [searchParams] = useSearchParams();
+ const projectId = useMemo(() => parseProjectId(searchParams.get("project_id")), [searchParams]);
+
+ const [search, setSearch] = useState("");
+ const [routeMode, setRouteMode] = useState("all");
+ const [rating, setRating] = useState("all");
+ const [mismatchOnly, setMismatchOnly] = useState(true);
+ const [clarifyOnly, setClarifyOnly] = useState(false);
+ const [selectedRecord, setSelectedRecord] = useState(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const deferredSearch = useDeferredValue(search);
+
+ const summaryQuery = useQuery({
+ queryKey: ["query-route-governance-summary", projectId],
+ queryFn: () =>
+ queryRouteGovernanceApi.getSummary({
+ project_id: projectId,
+ window_days: 30,
+ }),
+ enabled: canOperate,
+ });
+
+ const recordsQuery = useQuery({
+ queryKey: [
+ "query-route-governance-records",
+ projectId,
+ routeMode,
+ rating,
+ mismatchOnly,
+ clarifyOnly,
+ deferredSearch,
+ ],
+ queryFn: () =>
+ queryRouteGovernanceApi.listRecords({
+ project_id: projectId,
+ window_days: 30,
+ route_mode: routeMode === "all" ? undefined : routeMode,
+ rating: rating === "all" ? undefined : rating,
+ mismatch_only: mismatchOnly || undefined,
+ clarify_only: clarifyOnly || undefined,
+ search: deferredSearch.trim() || undefined,
+ limit: 50,
+ offset: 0,
+ }),
+ enabled: canOperate,
+ });
+
+ const detailQuery = useQuery({
+ queryKey: ["query-route-governance-detail", selectedRecord?.id, projectId],
+ queryFn: () => queryRouteGovernanceApi.getDetail(Number(selectedRecord?.id), { project_id: projectId }),
+ enabled: canOperate && detailOpen && selectedRecord != null,
+ });
+
+ if (!canOperate) {
+ return (
+
+
+ 仅 `BOSS / ADMIN` 可访问 route/context 治理页。
+
+
+ );
+ }
+
+ const errorText =
+ summaryQuery.error instanceof Error
+ ? summaryQuery.error.message
+ : recordsQuery.error instanceof Error
+ ? recordsQuery.error.message
+ : "请检查后端服务。";
+
+ return (
+
+ }
+ tone="blue"
+ meta={
+
+ {projectId ? `项目 #${projectId}` : "全局"}
+ {" · "}
+ {summaryQuery.isLoading ? "加载中" : summaryQuery.isError || recordsQuery.isError ? "读取失败" : "已接入"}
+
+ }
+ />
+
+
+
+ {summaryQuery.isError || recordsQuery.isError ? (
+
+
+ 治理页读取失败:{errorText}
+
+
+ ) : null}
+
+
+
+ {
+ startTransition(() => {
+ setSelectedRecord(item);
+ setDetailOpen(true);
+ });
+ }}
+ />
+
+ {
+ setDetailOpen(open);
+ if (!open) {
+ setSelectedRecord(null);
+ }
+ }}
+ />
+
+
+
+
+ 这页只做只读治理,不在这里在线自动调路由或改阈值。
+
+
+
+ );
+}
+
diff --git a/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx
new file mode 100644
index 00000000..0adaadb8
--- /dev/null
+++ b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceDetailDialog.tsx
@@ -0,0 +1,84 @@
+import type { QueryRouteGovernanceDetail } from "@/api/queryRouteGovernance";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+interface QueryRouteGovernanceDetailDialogProps {
+ open: boolean;
+ loading?: boolean;
+ detail: QueryRouteGovernanceDetail | null;
+ onOpenChange: (open: boolean) => void;
+}
+
+function renderJsonBlock(value: unknown): string {
+ try {
+ return JSON.stringify(value ?? {}, null, 2);
+ } catch {
+ return String(value ?? "");
+ }
+}
+
+export function QueryRouteGovernanceDetailDialog({
+ open,
+ loading = false,
+ detail,
+ onOpenChange,
+}: QueryRouteGovernanceDetailDialogProps) {
+ return (
+
+ );
+}
+
diff --git a/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx
new file mode 100644
index 00000000..837b2aac
--- /dev/null
+++ b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceOverviewCards.tsx
@@ -0,0 +1,67 @@
+import { Compass, ExternalLink, MessageCircleWarning, Siren } from "lucide-react";
+
+import type { QueryRouteGovernanceSummary } from "@/api/queryRouteGovernance";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+interface QueryRouteGovernanceOverviewCardsProps {
+ summary: QueryRouteGovernanceSummary | null | undefined;
+ loading?: boolean;
+}
+
+const CARD_ITEMS = [
+ {
+ key: "total_records",
+ label: "近窗样本",
+ hint: "当前治理窗口内保留的 query route 样本总量",
+ icon: Compass,
+ },
+ {
+ key: "mismatch_count",
+ label: "误路由样本",
+ hint: "predicted 和 effective 不一致,需要优先排查",
+ icon: Siren,
+ },
+ {
+ key: "clarify_count",
+ label: "澄清偏重",
+ hint: "clarify 或 uncertain 样本,说明入口和语义信息还不够稳",
+ icon: MessageCircleWarning,
+ },
+ {
+ key: "negative_feedback_count",
+ label: "负反馈",
+ hint: "用户明确给了 negative rating 的路由样本",
+ icon: ExternalLink,
+ },
+] as const;
+
+export function QueryRouteGovernanceOverviewCards({
+ summary,
+ loading = false,
+}: QueryRouteGovernanceOverviewCardsProps) {
+ const kpis = summary?.kpis;
+
+ return (
+
+ {CARD_ITEMS.map(({ key, label, hint, icon: Icon }) => (
+
+
+
+
+
+
+
+
+
+ {loading ? "--" : Number(kpis?.[key] || 0)}
+
+
+
+ ))}
+
+ );
+}
+
diff --git a/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx
new file mode 100644
index 00000000..4a068b49
--- /dev/null
+++ b/frontend/src/pages/query-route-governance/components/QueryRouteGovernanceTable.tsx
@@ -0,0 +1,228 @@
+import { Search } from "lucide-react";
+
+import type { QueryRouteGovernanceRecord } from "@/api/queryRouteGovernance";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+interface QueryRouteGovernanceTableProps {
+ items: QueryRouteGovernanceRecord[];
+ total: number;
+ loading?: boolean;
+ search: string;
+ routeMode: string;
+ rating: string;
+ mismatchOnly: boolean;
+ clarifyOnly: boolean;
+ onSearchChange: (value: string) => void;
+ onRouteModeChange: (value: string) => void;
+ onRatingChange: (value: string) => void;
+ onMismatchOnlyChange: (value: boolean) => void;
+ onClarifyOnlyChange: (value: boolean) => void;
+ onOpenDetail: (item: QueryRouteGovernanceRecord) => void;
+}
+
+function routeBadgeLabel(value?: string | null): string {
+ switch (String(value || "").trim().toLowerCase()) {
+ case "internal":
+ return "内部";
+ case "external":
+ return "外部";
+ case "uncertain":
+ return "待澄清";
+ default:
+ return "未知";
+ }
+}
+
+function routeBadgeClass(value?: string | null): string {
+ switch (String(value || "").trim().toLowerCase()) {
+ case "internal":
+ return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200";
+ case "external":
+ return "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200";
+ case "uncertain":
+ return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200";
+ default:
+ return "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200";
+ }
+}
+
+function feedbackLabel(value?: string | null): string {
+ switch (String(value || "").trim().toLowerCase()) {
+ case "negative":
+ return "负反馈";
+ case "positive":
+ return "正反馈";
+ default:
+ return "未标注";
+ }
+}
+
+export function QueryRouteGovernanceTable({
+ items,
+ total,
+ loading = false,
+ search,
+ routeMode,
+ rating,
+ mismatchOnly,
+ clarifyOnly,
+ onSearchChange,
+ onRouteModeChange,
+ onRatingChange,
+ onMismatchOnlyChange,
+ onClarifyOnlyChange,
+ onOpenDetail,
+}: QueryRouteGovernanceTableProps) {
+ return (
+
+
+
+
+
路由样本列表
+
+ 聚焦 `mismatch / clarify / negative feedback`,先把最有价值的问题样本看见。
+
+
+
共 {total} 条样本
+
+
+
+
+
+
+
+
+
+ 问题
+ 路由
+ 标记
+ 锚点
+ 操作
+
+
+
+ {loading ? (
+
+
+ 正在加载治理样本...
+
+
+ ) : items.length === 0 ? (
+
+
+ 当前筛选条件下没有样本。
+
+
+ ) : (
+ items.map((item) => (
+
+
+
+
{item.question_text}
+
+ {item.session_title || "未命名会话"}
+ {item.asked_via ? ` · ${item.asked_via}` : ""}
+
+
+
+
+
+
+ 预测 {routeBadgeLabel(item.route_mode_predicted)}
+
+
+ 实际 {routeBadgeLabel(item.route_mode_effective)}
+
+
+
+
+
+ {item.is_route_mismatch ? mismatch : null}
+ {item.is_clarify ? clarify : null}
+
+ {feedbackLabel(item.feedback_rating)}
+
+
+
+
+ {item.anchor_summary || "无显式锚点"}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
index bbd86945..554c9bda 100644
--- a/frontend/src/router/index.tsx
+++ b/frontend/src/router/index.tsx
@@ -23,6 +23,7 @@ import ContractSyncBoardPage from "@/pages/ContractSyncBoardPage";
import ModelRoutingConfigPage from "@/pages/ModelRoutingConfigPage";
import KnowledgeGraphContextBoardPage from "@/pages/KnowledgeGraphContextBoardPage";
import MCPConfigPage from "@/pages/MCPConfigPage";
+import QueryRouteGovernancePage from "@/pages/QueryRouteGovernancePage";
import SemanticFactsBoardPage from "@/pages/SemanticFactsBoardPage";
import { canManageTeamWorkload, isManagerRole } from "@/lib/rbac";
@@ -97,6 +98,7 @@ export const router = createBrowserRouter(
{ path: "ai-config/task-brief-runs", element: },
{ path: "ai-config/contract-sync-board", element: },
{ path: "ai-config/mcp-config", element: },
+ { path: "ai-config/query-route-governance", element: },
{ path: "ai-config/model-routing-config", element: },
{ path: "ai-config/knowledge-graph-board", element: },
{ path: "ai-config/semantic-facts-board", element: },
@@ -105,6 +107,7 @@ export const router = createBrowserRouter(
{ path: "task-brief-runs", element: },
{ path: "contract-sync-board", element: },
{ path: "mcp-config", element: },
+ { path: "query-route-governance", element: },
{ path: "model-routing-config", element: },
{ path: "knowledge-graph-board", element: },
{ path: "semantic-facts-board", element: },
diff --git a/frontend/src/types/inbox.ts b/frontend/src/types/inbox.ts
index 69e6c721..1aa15e49 100644
--- a/frontend/src/types/inbox.ts
+++ b/frontend/src/types/inbox.ts
@@ -1,4 +1,4 @@
-import type { AIPMOApprovalRequirement, AIPMOWorkItem } from "@/api/aiPmo";
+import type { AIPMOApprovalRequirement, AIPMORequirementPlanContextSummary, AIPMOWorkItem } from "@/api/aiPmo";
import type { MountedAIRunItem } from "@/api/mountedAi";
import type { NotificationItem } from "@/api/notifications";
@@ -47,6 +47,9 @@ export interface InboxItemViewModel {
next_step_text?: string | null;
latest_policy_gate_code?: string | null;
approval_requirement?: AIPMOApprovalRequirement | null;
+ plan_mode?: boolean;
+ plan_status?: string | null;
+ requirement_plan_context_summary?: AIPMORequirementPlanContextSummary | null;
source_notification?: NotificationItem;
source_work_item?: AIPMOWorkItem;
source_mounted_run?: MountedAIRunItem;
diff --git a/mdproject/PLAN.md b/mdproject/PLAN.md
index 87e2f878..a6cb8272 100644
--- a/mdproject/PLAN.md
+++ b/mdproject/PLAN.md
@@ -84,13 +84,26 @@
## 测试与验收
+本期验收不绑定固定问句、固定 provider、固定 prompt 名或固定内部实现路径。
+验收只看 `route_mode=external` 后,external-only 主链是否满足行为合同。
+
+### 验收要求
+
+- 一旦进入 external 主链,最终回答只能基于 external evidence 生成,不能混入 `manifest_text / project_context / system_snapshot / user_snapshot / 内部 retrieval block`。
+- external 证据不足时,必须明确返回 `insufficient_evidence` 或 `clarify`,不能假装已经查实。
+- 对同一 external 问题的后续追问,只要语义上仍是在继续核实,就必须继续留在 external `query.answer` 主链,不能误跳到 requirement-plan、draft confirm 或内部执行链。
+- external source filtering 在过滤后若无合格来源,必须按空来源或证据不足处理,不能把内部来源重新塞回 external answer。
+- 当前实现可以按 adapter 能力选择不同 external 取证方式,但验收不把某一种具体 provider 顺序写死为长期真源。
+- 运行时必须保留清晰审计信息,至少能看出:planner 如何产出计划、gateway 如何取证、verifier 为何通过或失败、presenter 如何生成最终回答。
+
+### 覆盖范围
+
- planner:
- 结构化 planner 失败时,不使用正则或硬词表补 `weather/news/company`
- fallback 仍能产出可执行 `search_queries`
- gateway:
- - 天气 query 优先 `live_search`
- - 文档 query 优先 `mcp/doc_search`
- - MCP/live_search 都能抓目标页正文,而不是只用标题摘要
+ - 不同类型的 external 问题都能进入受控 adapter 取证,而不是回退到旧 scene prompt 混答
+ - external adapter 返回搜索结果后,仍需尽量抓取目标页正文,而不是长期只依赖标题摘要
- verifier:
- `湛江市 / 湛江` 这类归一化对象不再误杀
- 时间范围不匹配时明确 `insufficient_evidence`
@@ -98,14 +111,14 @@
- presenter:
- external route 不再命中 `industry_trend / external_trend`
- prompt 输入中不包含 `[M] 项目画像`、模块规模、知识图谱、system snapshot
- - 最终回答只基于外部 evidence 生成
+ - 最终回答只基于 external evidence 生成
- 端到端:
- - 从飞书入口发“湛江市未来五天的天气”,整条链应表现为:
- - `route_mode=external`
- - provider 选中 `live_search`
- - verifier 通过
- - presenter 输出 `外部实时查询:...`
- - 回答内带来源摘要且无内部上下文污染
+ - 覆盖带时间范围或地点的公开事实问题
+ - 覆盖一般性的外部事实或动态问题
+ - 覆盖证据不足后的继续核实
+ - 覆盖 external 来源被过滤为空的情况
+ - 覆盖某一种 external 取证方式不可用、需要切换备选方式的情况
+ - 覆盖需要多个来源交叉支撑的对比问题
## 假设与默认
diff --git a/scripts/ai_steward_eval.py b/scripts/ai_steward_eval.py
index f46bbf4e..c016f0de 100755
--- a/scripts/ai_steward_eval.py
+++ b/scripts/ai_steward_eval.py
@@ -23,15 +23,6 @@ try:
except Exception: # pragma: no cover
httpx = None
-
-DEFAULT_FALLBACK_PATTERNS = [
- "未找到足够相关的信息",
- "请补充项目名",
- "请补充关键词",
- "信息不足",
-]
-
-
@dataclass
class EvalCase:
id: str
@@ -187,7 +178,7 @@ def _is_hallucination(answer: str, forbidden: list[str]) -> bool:
lowered = str(answer or "").lower()
if _find_forbidden(lowered, forbidden):
return True
- return _contains_any(lowered, DEFAULT_FALLBACK_PATTERNS)
+ return not lowered.strip()
def _evaluate_case(case: EvalCase, payload: dict[str, Any]) -> EvalResult:
diff --git a/scripts/ai_steward_pressure_coach.py b/scripts/ai_steward_pressure_coach.py
index 87cc2cad..4e2a82c5 100755
--- a/scripts/ai_steward_pressure_coach.py
+++ b/scripts/ai_steward_pressure_coach.py
@@ -25,38 +25,7 @@ from typing import Any, Iterable
import httpx
-
from backend.app.utils.timezone import now_cn_aware
-DEFAULT_TEMPLATE_LEAK_PATTERNS = [
- "当前系统采用前后端分层架构",
- "fastapi + sqlalchemy",
- "postgresql (pgvector-ready)",
- "后端模块规模",
-]
-
-ENGINE_QUERY_MARKERS = [
- "ai引擎",
- "本地路由层",
- "调用链",
- "模型网关",
- "路由",
-]
-
-CORRECTION_MARKERS = [
- "不是",
- "不对",
- "错了",
- "我说的是",
- "纠正",
-]
-
-ARCHITECTURE_MARKERS = [
- "系统架构",
- "技术架构",
- "前后端",
- "整体架构",
- "架构",
-]
@dataclass(frozen=True)
@@ -320,53 +289,13 @@ def _build_scenarios(mode: str) -> list[Scenario]:
return _build_rag_scenarios()
-def _is_engine_or_correction_question(question: str) -> bool:
- q = str(question or "").strip().lower()
- if not q:
- return False
- return any(k in q for k in ENGINE_QUERY_MARKERS) or any(k in q for k in CORRECTION_MARKERS)
-
-
-def _is_plain_architecture_question(question: str) -> bool:
- q = str(question or "").strip().lower()
- if not q:
- return False
- has_arch = any(k in q for k in ARCHITECTURE_MARKERS)
- has_engine = any(k in q for k in ENGINE_QUERY_MARKERS)
- has_correction = any(k in q for k in CORRECTION_MARKERS)
- if not has_arch:
- return False
- # “纠错 + 架构词”不算纯架构问法,比如:不是系统架构,我要AI引擎路由。
- return (not has_engine) and (not has_correction)
-
-
def _contains_template_leak(answer: str) -> bool:
- text = str(answer or "").strip().lower()
- if not text:
- return False
- return any(pattern.lower() in text for pattern in DEFAULT_TEMPLATE_LEAK_PATTERNS)
+ _ = answer
+ return False
-def _is_requirement_like_question(question: str) -> bool:
- q = str(question or "").strip().lower()
- if not q:
- return False
- has_question = ("?" in q) or ("?" in q) or any(k in q for k in ("吗", "是否", "要不要", "能不能", "可不可以"))
- if not has_question:
- return False
- verb_hits = ("新增", "增加", "加一个", "加个", "支持", "接入", "实现", "优化", "修复", "改进", "同步")
- object_hits = ("功能", "能力", "接口", "页面", "按钮", "流程", "模块", "需求", "项目", "队列", "信号")
- return any(v in q for v in verb_hits) and any(o in q for o in object_hits)
-
-
-def _pick_negative_reason(question: str) -> tuple[list[str], str]:
- q = str(question or "").strip()
- if _is_engine_or_correction_question(q):
- return (
- ["答非所问", "答案错误", "来源错误"],
- "请基于项目AI引擎调用链回答:入口、路由、模型网关分别是什么;证据不足就明确写“不确定”。",
- )
- return (["答案错误"], "请结合证据回答,并避免使用通用模板。")
+def _pick_negative_reason() -> tuple[list[str], str]:
+ return (["答案错误"], "请结合证据回答,并明确标记不确定部分。")
async def _login_token(
@@ -525,9 +454,7 @@ async def _run_worker(
sources = rag_payload.get("sources") if isinstance(rag_payload.get("sources"), list) else []
intent = str(payload.get("intent") or "").strip().lower()
direct_reply_required = bool(payload.get("direct_reply_required"))
- requirement_sync_expected = bool(
- turn.expect_requirement_sync or _is_requirement_like_question(turn.question)
- )
+ requirement_sync_expected = bool(turn.expect_requirement_sync)
has_sync_artifact = bool(payload.get("results")) or (payload.get("signal_id") is not None)
requirement_sync_hit = bool(
requirement_sync_expected
@@ -575,9 +502,7 @@ async def _run_worker(
break
template_leak = False
- if (turn.engine_sensitive or _is_engine_or_correction_question(turn.question)) and (
- not _is_plain_architecture_question(turn.question)
- ):
+ if turn.engine_sensitive:
template_leak = _contains_template_leak(answer)
if (
@@ -620,7 +545,7 @@ async def _run_worker(
if auto_feedback and ok and qa_id is not None:
if template_leak:
- error_types, correct_answer = _pick_negative_reason(turn.question)
+ error_types, correct_answer = _pick_negative_reason()
fb_payload = {
"rating": "negative",
"error_types": error_types,
diff --git a/scripts/semantic_lint.py b/scripts/semantic_lint.py
index f7ef5594..a738eecc 100644
--- a/scripts/semantic_lint.py
+++ b/scripts/semantic_lint.py
@@ -15,10 +15,15 @@ DEFAULT_SCAN_ROOTS = (
Path("backend/app/services/adaptive_rag_service"),
Path("backend/app/services/external_query_service"),
Path("backend/app/services/integrations"),
+ Path("backend/app/services/reply_iteration"),
+ Path("backend/app/services/blocker_detection_core"),
Path("backend/app/services/signal"),
Path("backend/app/api/ai_pmo_api"),
+ Path("frontend/src"),
+ Path("scripts"),
)
ALLOWLIST_PREFIXES = (
+ Path("scripts/semantic_lint.py"),
Path("backend/app/config/ai_prompt_registry.py"),
Path("backend/app/config/ai_prompts.py"),
Path("backend/app/config/ai_prompts_catalog"),
@@ -61,10 +66,27 @@ RULES: tuple[Rule, ...] = (
Rule(
rule_id="semantic_regex_constant",
pattern=re.compile(
- r"\b[A-Z][A-Z0-9_]*(?:PROJECT|TASK|SIGNAL|FOLLOWUP|COMMAND|QUERY|SOURCE|INTENT|EXTERNAL|CORRECTION)[A-Z0-9_]*_RE\b\s*=\s*re\.compile"
+ r"\b[A-Z0-9_]*(?:PROJECT|TASK|SIGNAL|FOLLOWUP|COMMAND|QUERY|SOURCE|INTENT|EXTERNAL|CORRECTION|PROGRESS|BLOCKER|WORK_ITEM|RISK)[A-Z0-9_]*_RE\b\s*=\s*re\.compile"
),
message="business routing regex should live in workflow_semantic_planner",
),
+ Rule(
+ rule_id="semantic_term_constant",
+ pattern=re.compile(r"\b[A-Z][A-Z0-9_]*(?:MARKERS|PREFIXES|PATTERNS|REMOVABLE_TOKENS|RULES)\b\s*=\s*(?:\(|\[|\{)"),
+ message="business term tables are forbidden outside structured semantic/planner layers",
+ ),
+ Rule(
+ rule_id="semantic_lowercase_term_table",
+ pattern=re.compile(
+ r"\b(?:strict_keywords|time_keywords|forbidden|deployment_hints|direct_patterns)\b\s*=\s*(?:\(|\[)"
+ ),
+ message="inline lowercase business term tables are forbidden outside structured semantic/planner layers",
+ ),
+ Rule(
+ rule_id="semantic_keywords_property",
+ pattern=re.compile(r"\bkeywords\s*:\s*\["),
+ message="frontend keyword arrays are forbidden; use label/description or server-provided metadata instead",
+ ),
Rule(
rule_id="looks_like_helper",
pattern=re.compile(