feat: execute ai quality improvement actions

This commit is contained in:
2026-06-23 10:49:30 +08:00
parent a756a0dd93
commit f1fcf90797
8 changed files with 607 additions and 3 deletions
@@ -0,0 +1,79 @@
# V2 AI 质量改进动作执行闭环记录 - 2026-06-23
## 路线核查
本轮继续对照《AI 合同平台 V2 重构版开发方案》推进,没有跑偏:
- AI 质量归因、Prompt、知识库、审计仍归属 `yuqei-ai-platform`
- 合同侧前端只通过 `/api/ai-platform/*` 代理调用 AI 中台,不直接拼模型请求、不直接操作 AI 内部仓库。
-`shared-*` 仓库没有新增能力,也没有恢复 vendored SDK。
- 本轮仍围绕 “AI 中台质量评测 -> 归因 -> 改进动作 -> 真实变更 -> 效果回看” 主线推进。
## 本轮完成内容
### 1. 改进项完成前校验
AI Platform 后端新增改进项完成校验:
- `tune_prompt` 类型必须关联 `linked_prompt_template_id``linked_prompt_template_version` 后才能标记 `resolved`
- `improve_knowledge` 类型必须关联 `linked_knowledge_base_id``linked_import_batch_id` 后才能标记 `resolved`
- 缺少真实变更对象时,API 返回 `422` 和明确原因,避免“纸面完成”。
### 2. Prompt 改进执行入口
质量运营看板的 Prompt 类改进项新增:
- “生成 Prompt 草稿”入口。
- 自动基于当前 Prompt 或默认模板生成新草稿。
- 自动把新草稿的 Prompt ID/版本回填到改进项。
- 管理员可继续进入 Prompt 中心编辑、发布和观察后续指标。
### 3. 知识库改进执行入口
质量运营看板的知识库类改进项新增:
- 轻量补知识库表单。
- 创建知识库导入批次。
- 写入知识文档。
- 自动把知识库 ID 和导入批次 ID 回填到改进项。
### 4. 前端代理补齐
合同侧前端新增/增强 AI Platform 代理:
- `POST /api/ai-platform/knowledge-import-batches`
- `POST /api/ai-platform/knowledge-documents`
这些代理只承担管理后台调用转发,不改变 AI 能力归属边界。
## 闭环价值
本轮之后,质量运营链路从:
```text
发现问题 -> 生成改进项 -> 手工标记完成
```
升级为:
```text
发现问题 -> 生成改进项 -> 生成 Prompt 草稿 / 补知识库 -> 挂真实变更 -> 再标记完成 -> 后续指标回看
```
这样 `调整 Prompt``补知识库` 不再只是文字状态,而是能追溯到真实配置变更记录。
## 验证重点
- 没有真实 Prompt/知识库关联时,改进项不能标记完成。
- Prompt 改进项能生成草稿并回填关联。
- 知识库改进项能创建导入批次、写入文档并回填关联。
- 质量看板仍能加载、筛选、更新改进项状态。
## 下一步建议
下一步可以进入 “改进效果复盘增强”:
- 将改进项完成时间作为效果观察窗口起点。
- 在质量看板中展示完成前后异常率对比。
- 针对同一个 Prompt/知识库变更聚合关联的多个改进项。
- 给 Prompt 发布、知识库导入增加更明确的变更审计事件。
@@ -321,6 +321,8 @@ def create_app(
return store.update_quality_improvement_action(action_id, payload)
except KeyError as error:
raise HTTPException(status_code=404, detail="AI quality improvement action not found.") from error
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
@app.patch(
f"{resolved_settings.api_prefix}/audit/ai-runs/{{run_id}}/quality-labels/{{label_code}}",
@@ -908,6 +908,16 @@ class InMemoryAiPlatformRepository:
action = self.quality_improvement_actions.get(action_id)
if action is None:
raise KeyError(action_id)
_validate_quality_action_completion(
action_type=action.action_type,
status=payload.status,
linked_prompt_template_id=payload.linked_prompt_template_id or action.linked_prompt_template_id,
linked_prompt_template_version=(
payload.linked_prompt_template_version or action.linked_prompt_template_version
),
linked_knowledge_base_id=payload.linked_knowledge_base_id or action.linked_knowledge_base_id,
linked_import_batch_id=payload.linked_import_batch_id or action.linked_import_batch_id,
)
update: dict[str, object] = {
"status": payload.status,
"updated_at": now,
@@ -1684,6 +1694,16 @@ class SqlAlchemyAiPlatformRepository:
row = session.get(AiQualityImprovementActionModel, action_id)
if row is None:
raise KeyError(action_id)
_validate_quality_action_completion(
action_type=row.action_type,
status=payload.status,
linked_prompt_template_id=payload.linked_prompt_template_id or row.linked_prompt_template_id,
linked_prompt_template_version=(
payload.linked_prompt_template_version or row.linked_prompt_template_version
),
linked_knowledge_base_id=payload.linked_knowledge_base_id or row.linked_knowledge_base_id,
linked_import_batch_id=payload.linked_import_batch_id or row.linked_import_batch_id,
)
row.status = payload.status
if payload.owner is not None:
row.owner = payload.owner
@@ -2032,6 +2052,29 @@ def _with_inferred_action_links(
return payload.model_copy(update=update) if update else payload
def _validate_quality_action_completion(
*,
action_type: str,
status: str,
linked_prompt_template_id: str | None,
linked_prompt_template_version: str | None,
linked_knowledge_base_id: str | None,
linked_import_batch_id: str | None,
) -> None:
if status != "resolved":
return
if action_type == "tune_prompt" and not (linked_prompt_template_id and linked_prompt_template_version):
raise ValueError(
"Prompt improvement actions require linked_prompt_template_id and "
"linked_prompt_template_version before they can be resolved."
)
if action_type == "improve_knowledge" and not (linked_knowledge_base_id and linked_import_batch_id):
raise ValueError(
"Knowledge improvement actions require linked_knowledge_base_id and "
"linked_import_batch_id before they can be resolved."
)
def _find_prompt_template_link(
key: str,
prompt_templates: Iterable[PromptTemplate],
@@ -1,3 +1,4 @@
import pytest
from fastapi.testclient import TestClient
from yuqei_ai_platform_api.config import AiPlatformSettings
@@ -373,6 +374,82 @@ def test_quality_improvement_action_links_prompt_and_knowledge_changes() -> None
assert updated.linked_import_batch_id == "kb-batch-manual"
def test_quality_improvement_action_requires_real_change_before_resolved() -> None:
repository = InMemoryAiPlatformRepository()
prompt_action = repository.create_quality_improvement_action(
AiQualityImprovementActionCreate(
dimension="label_code",
key="prompt_missing_citation",
label="Prompt missing citation",
action_type="tune_prompt",
evaluation_days=7,
)
)
with pytest.raises(ValueError, match="Prompt improvement actions require"):
repository.update_quality_improvement_action(
prompt_action.id,
AiQualityImprovementActionUpdate(status="resolved"),
)
knowledge_action = repository.create_quality_improvement_action(
AiQualityImprovementActionCreate(
dimension="label_code",
key="source_limited",
label="Source limited",
action_type="improve_knowledge",
evaluation_days=7,
)
)
with pytest.raises(ValueError, match="Knowledge improvement actions require"):
repository.update_quality_improvement_action(
knowledge_action.id,
AiQualityImprovementActionUpdate(status="resolved"),
)
batch = repository.create_knowledge_import_batch(
KnowledgeImportBatchCreate(
knowledge_base_id="default",
source_name="quality-action-source-fix",
source_type="quality_improvement",
)
)
resolved = repository.update_quality_improvement_action(
knowledge_action.id,
AiQualityImprovementActionUpdate(
status="resolved",
linked_knowledge_base_id="default",
linked_import_batch_id=batch.id,
resolution_note="Knowledge base was supplemented.",
),
)
assert resolved.status == "resolved"
assert resolved.linked_import_batch_id == batch.id
def test_quality_improvement_action_resolve_api_returns_422_without_real_change() -> None:
client = make_client()
create_response = client.post(
"/api/v1/audit/quality-improvement-actions",
json={
"dimension": "label_code",
"key": "source_limited",
"label": "Source limited",
"action_type": "improve_knowledge",
"evaluation_days": 7,
},
)
assert create_response.status_code == 200
action = create_response.json()
resolve_response = client.patch(
f"/api/v1/audit/quality-improvement-actions/{action['id']}",
json={"status": "resolved"},
)
assert resolve_response.status_code == 422
assert "linked_knowledge_base_id" in resolve_response.json()["detail"]
def test_knowledge_document_can_be_added_and_searched() -> None:
client = make_client()
@@ -5,9 +5,12 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import {
AlertTriangle,
BookOpen,
CheckCircle2,
Clock3,
Database,
FilePlus2,
PencilLine,
PlayCircle,
RefreshCcw,
ShieldCheck,
@@ -120,6 +123,7 @@ type PromptTemplateOption = {
id: string;
name: string;
version: string;
content?: string;
status: string;
published_at?: string | null;
updated_at: string;
@@ -147,6 +151,13 @@ type ActionFilters = {
dimension: string;
};
type KnowledgeDraft = {
sourceName: string;
title: string;
reference: string;
content: string;
};
const severityTone: Record<string, string> = {
info: "tag-primary",
warning: "tag-warning",
@@ -205,6 +216,7 @@ export default function AiQualityDashboardPage() {
const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
const [promptOptions, setPromptOptions] = useState<PromptTemplateOption[]>([]);
const [importBatchOptions, setImportBatchOptions] = useState<KnowledgeImportBatchOption[]>([]);
const [knowledgeDrafts, setKnowledgeDrafts] = useState<Record<string, KnowledgeDraft>>({});
const [filters, setFilters] = useState<ActionFilters>({
status: "",
owner: "",
@@ -295,7 +307,7 @@ export default function AiQualityDashboardPage() {
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
throw new Error(readApiError(payload, response.status));
}
setActionMessage("已生成改进项,可以在下方队列继续处理。");
await loadLinkOptions();
@@ -334,6 +346,139 @@ export default function AiQualityDashboardPage() {
}
}
async function createPromptDraftForAction(action: QualityImprovementAction) {
const busyKey = actionBusyKeyFor(action, "prompt-draft");
setActionBusyKey(busyKey);
setActionMessage(null);
try {
const sourcePrompt = findPromptOption(action.linked_prompt_template_id || action.key, promptOptions);
const promptName = sourcePrompt?.name || (action.key.includes(":") ? action.key.split(":")[0] : "legal_qa.default");
const version = `quality-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}-${action.id.slice(-4)}`;
const response = await fetch("/api/ai-platform/prompt-templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: promptName,
version,
content: buildPromptDraftContent(action, sourcePrompt?.content),
enabled: false
})
});
const prompt = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(readApiError(prompt, response.status));
}
const updateResponse = await fetch(`/api/ai-platform/audit/quality-improvement-actions/${encodeURIComponent(action.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "in_progress",
owner: "admin",
linked_prompt_template_id: prompt.id,
linked_prompt_template_version: prompt.version,
resolution_note: "已生成 Prompt 草稿,待编辑发布并继续观察质量指标。"
})
});
const updatePayload = await updateResponse.json().catch(() => null);
if (!updateResponse.ok) {
throw new Error(readApiError(updatePayload, updateResponse.status));
}
setActionMessage(`已生成 Prompt 草稿 ${prompt.name} / ${prompt.version},可进入 Prompt 中心编辑发布。`);
await loadLinkOptions();
await loadDashboard(days);
} catch (error) {
setActionMessage(error instanceof Error ? error.message : "生成 Prompt 草稿失败");
} finally {
setActionBusyKey(null);
}
}
function updateKnowledgeDraft(action: QualityImprovementAction, patch: Partial<KnowledgeDraft>) {
setKnowledgeDrafts((current) => ({
...current,
[action.id]: {
...defaultKnowledgeDraft(action),
...current[action.id],
...patch
}
}));
}
async function submitKnowledgeDraftForAction(action: QualityImprovementAction) {
const busyKey = actionBusyKeyFor(action, "knowledge-import");
const draft = knowledgeDrafts[action.id] ?? defaultKnowledgeDraft(action);
const knowledgeBaseId = action.linked_knowledge_base_id || (action.dimension === "knowledge_base" ? action.key : "default");
if (!draft.title.trim() || !draft.content.trim()) {
setActionMessage("请先填写知识标题和补充内容,再创建导入批次。");
return;
}
setActionBusyKey(busyKey);
setActionMessage(null);
try {
const batchResponse = await fetch("/api/ai-platform/knowledge-import-batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
knowledge_base_id: knowledgeBaseId,
source_name: draft.sourceName.trim() || `quality-action-${action.id}`,
source_type: "quality_improvement"
})
});
const batch = await batchResponse.json().catch(() => null);
if (!batchResponse.ok) {
throw new Error(readApiError(batch, batchResponse.status));
}
const documentResponse = await fetch("/api/ai-platform/knowledge-documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
import_batch_id: batch.id,
knowledge_base_id: knowledgeBaseId,
title: draft.title.trim(),
source_type: "quality_improvement",
reference: draft.reference.trim() || action.label,
content: draft.content.trim()
})
});
const documentPayload = await documentResponse.json().catch(() => null);
if (!documentResponse.ok) {
throw new Error(readApiError(documentPayload, documentResponse.status));
}
const updateResponse = await fetch(`/api/ai-platform/audit/quality-improvement-actions/${encodeURIComponent(action.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "in_progress",
owner: "admin",
linked_knowledge_base_id: knowledgeBaseId,
linked_import_batch_id: batch.id,
resolution_note: "已补充知识库导入批次,待后续 AI 调用验证来源不足率。"
})
});
const updatePayload = await updateResponse.json().catch(() => null);
if (!updateResponse.ok) {
throw new Error(readApiError(updatePayload, updateResponse.status));
}
setKnowledgeDrafts((current) => {
const next = { ...current };
delete next[action.id];
return next;
});
setActionMessage(`已写入知识库 ${knowledgeBaseId},导入批次 ${batch.id} 已关联到改进项。`);
await loadLinkOptions();
await loadDashboard(days);
} catch (error) {
setActionMessage(error instanceof Error ? error.message : "补充知识库失败");
} finally {
setActionBusyKey(null);
}
}
return (
<>
<div className="page-heading">
@@ -453,8 +598,12 @@ export default function AiQualityDashboardPage() {
<QualityActionItem
action={action}
busyKey={actionBusyKey}
knowledgeDraft={knowledgeDrafts[action.id] ?? defaultKnowledgeDraft(action)}
key={action.id}
onCreatePromptDraft={createPromptDraftForAction}
onSubmitKnowledgeDraft={submitKnowledgeDraftForAction}
onUpdate={updateAction}
onUpdateKnowledgeDraft={updateKnowledgeDraft}
/>
))}
</div>
@@ -604,16 +753,27 @@ function AttributionPanel({
function QualityActionItem({
action,
busyKey,
onUpdate
knowledgeDraft,
onCreatePromptDraft,
onSubmitKnowledgeDraft,
onUpdate,
onUpdateKnowledgeDraft
}: {
action: QualityImprovementAction;
busyKey: string | null;
knowledgeDraft: KnowledgeDraft;
onCreatePromptDraft: (action: QualityImprovementAction) => void | Promise<void>;
onSubmitKnowledgeDraft: (action: QualityImprovementAction) => void | Promise<void>;
onUpdate: (action: QualityImprovementAction, status: string) => void | Promise<void>;
onUpdateKnowledgeDraft: (action: QualityImprovementAction, patch: Partial<KnowledgeDraft>) => void;
}) {
const busy = busyKey?.startsWith(`${action.id}:`) ?? false;
const baselineRate = safeRate(action.baseline_affected_runs, action.baseline_total_runs);
const currentRate = safeRate(action.current_affected_runs, action.current_total_runs);
const effectStatus = action.effect_status ?? "pending_data";
const needsPromptDraft = action.action_type === "tune_prompt";
const needsKnowledgeImport = action.action_type === "improve_knowledge";
const completionBlockReason = getCompletionBlockReason(action);
return (
<div className="quality-action-item">
@@ -644,12 +804,81 @@ function QualityActionItem({
{action.resolution_note && <span className="item-meta">{action.resolution_note}</span>}
<LinkedChangeSummary action={action} />
{action.effect_summary && <span className="item-meta">{action.effect_summary}</span>}
{["open", "in_progress"].includes(action.status) && (needsPromptDraft || needsKnowledgeImport) && (
<div className="quality-action-execution">
{needsPromptDraft && (
<button className="button button-soft" disabled={busy} onClick={() => void onCreatePromptDraft(action)} type="button">
<PencilLine size={16} />
{busyKey === actionBusyKeyFor(action, "prompt-draft") ? "生成中" : "生成 Prompt 草稿"}
</button>
)}
{needsKnowledgeImport && (
<div className="quality-action-mini-form">
<div className="settings-grid">
<label className="setting-field">
<span></span>
<input
className="search-input"
readOnly
value={action.linked_knowledge_base_id || (action.dimension === "knowledge_base" ? action.key : "default")}
/>
</label>
<label className="setting-field">
<span></span>
<input
className="search-input"
value={knowledgeDraft.sourceName}
onChange={(event) => onUpdateKnowledgeDraft(action, { sourceName: event.target.value })}
/>
</label>
<label className="setting-field">
<span></span>
<input
className="search-input"
value={knowledgeDraft.title}
onChange={(event) => onUpdateKnowledgeDraft(action, { title: event.target.value })}
/>
</label>
<label className="setting-field">
<span>/</span>
<input
className="search-input"
value={knowledgeDraft.reference}
onChange={(event) => onUpdateKnowledgeDraft(action, { reference: event.target.value })}
/>
</label>
</div>
<label className="setting-field">
<span></span>
<textarea
className="search-input quality-action-textarea"
value={knowledgeDraft.content}
onChange={(event) => onUpdateKnowledgeDraft(action, { content: event.target.value })}
/>
</label>
<div className="action-row">
<button className="button button-soft" disabled={busy} onClick={() => void onSubmitKnowledgeDraft(action)} type="button">
<FilePlus2 size={16} />
{busyKey === actionBusyKeyFor(action, "knowledge-import") ? "写入中" : "写入知识库并关联"}
</button>
<span className="item-meta"></span>
</div>
</div>
)}
</div>
)}
<div className="action-row">
{action.linked_prompt_template_id && (
<Link className="button button-soft" href="/admin/ai-prompts">
Prompt
</Link>
)}
{action.linked_import_batch_id && (
<span className="button button-soft button-static">
<BookOpen size={16} />
</span>
)}
{action.status === "open" && (
<button className="button button-soft" disabled={busy} onClick={() => void onUpdate(action, "in_progress")} type="button">
<PlayCircle size={16} />
@@ -658,7 +887,13 @@ function QualityActionItem({
)}
{["open", "in_progress"].includes(action.status) && (
<>
<button className="button button-primary" disabled={busy} onClick={() => void onUpdate(action, "resolved")} type="button">
<button
className="button button-primary"
disabled={busy || Boolean(completionBlockReason)}
onClick={() => void onUpdate(action, "resolved")}
title={completionBlockReason || undefined}
type="button"
>
<CheckCircle2 size={16} />
</button>
@@ -785,6 +1020,73 @@ function defaultResolutionNote(status: string) {
return undefined;
}
function defaultKnowledgeDraft(action: QualityImprovementAction): KnowledgeDraft {
const knowledgeBaseId = action.linked_knowledge_base_id || (action.dimension === "knowledge_base" ? action.key : "default");
return {
sourceName: `quality-action-${action.id}`,
title: action.label || "质量改进补充知识",
reference: action.root_cause_hint || action.source_label_codes.join("、") || action.id,
content: [
action.root_cause_hint || action.description || "请补充能支撑 AI 回答或审查结论的法规、制度、模板或案例依据。",
"",
`关联知识库:${knowledgeBaseId}`,
`来源标签:${action.source_label_codes.join("、") || "无"}`
].join("\n")
};
}
function getCompletionBlockReason(action: QualityImprovementAction) {
if (action.action_type === "tune_prompt" && !(action.linked_prompt_template_id && action.linked_prompt_template_version)) {
return "请先生成或关联 Prompt 草稿/版本,再标记完成。";
}
if (action.action_type === "improve_knowledge" && !(action.linked_knowledge_base_id && action.linked_import_batch_id)) {
return "请先写入知识库导入批次,再标记完成。";
}
return null;
}
function buildPromptDraftContent(action: QualityImprovementAction, previousContent?: string) {
const source = previousContent?.trim() || [
"你是企业合同与法律问答助手。",
"回答前先检索知识库,回答必须包含明确依据、适用关系、风险提示和需要补充的信息。",
"如果没有检索到足够依据,必须说明当前依据不足,并建议人工复核。"
].join("\n");
return [
source,
"",
"# Quality improvement note",
`- Action: ${action.id}`,
`- Issue: ${action.title}`,
`- Root cause: ${action.root_cause_hint || action.description || "待补充"}`,
`- Labels: ${action.source_label_codes.join(", ") || "none"}`,
"",
"请在编辑后发布该草稿,并观察后续 AI Trace 的质量标签是否下降。"
].join("\n");
}
function readApiError(payload: unknown, status: number) {
if (payload && typeof payload === "object") {
const record = payload as { message?: unknown; detail?: unknown; payload?: unknown };
if (typeof record.message === "string") {
return record.message;
}
if (typeof record.detail === "string") {
return record.detail;
}
if (record.payload && typeof record.payload === "object") {
const nested = record.payload as { message?: unknown; detail?: unknown };
if (typeof nested.detail === "string") {
return nested.detail;
}
if (typeof nested.message === "string") {
return nested.message;
}
}
}
return `HTTP ${status}`;
}
function attributionBusyKey(item: QualityAttributionItem) {
return `create:${item.dimension}:${item.key}`;
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function POST(request: Request) {
const targetUrl = new URL("/api/v1/knowledge-documents", aiPlatformBaseUrl);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform knowledge document service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform knowledge document service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -36,3 +36,37 @@ export async function GET(request: Request) {
);
}
}
export async function POST(request: Request) {
const targetUrl = new URL("/api/v1/knowledge-import-batches", aiPlatformBaseUrl);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform knowledge import batch service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform knowledge import batch service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -812,6 +812,33 @@ select {
background: #f1f5f9;
}
.quality-action-execution {
display: grid;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
}
.quality-action-mini-form {
display: grid;
gap: 10px;
min-width: 0;
}
.quality-action-textarea {
min-height: 110px;
padding: 10px;
resize: vertical;
line-height: 1.55;
}
.button-static {
pointer-events: none;
}
.trend-list {
display: grid;
gap: 10px;