Strengthen AI system charter and governance constraints
This commit is contained in:
parent
a01c451195
commit
6bb8354b8d
79
AGENTS.md
79
AGENTS.md
|
|
@ -78,7 +78,7 @@ frontend/ # React 前端
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 服务边界约束(禁止越界)
|
## 8. 服务边界约束与 AI 系统宪章(禁止越界)
|
||||||
|
|
||||||
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||||
|
|
@ -88,6 +88,83 @@ frontend/ # React 前端
|
||||||
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
|
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
|
||||||
- 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
|
- 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析
|
||||||
|
|
||||||
|
### 8.1 系统定位
|
||||||
|
|
||||||
|
- PMFlow 的 AI 不是聊天功能,而是事实驱动的决策、生成与执行系统。
|
||||||
|
- 系统目标不是输出“看起来完整”的答案,而是输出可验证、可追溯、可执行的结果。
|
||||||
|
- 任何 AI 能力都必须服务于业务闭环,不得脱离业务事实自由发挥。
|
||||||
|
|
||||||
|
### 8.2 核心原则
|
||||||
|
|
||||||
|
- 真源优先:内部已验证事实优先于反馈记录,反馈记录优先于外部知识,外部知识优先于模型推断。
|
||||||
|
- 证据优先:先找证据,再下结论;证据不足时必须显式标注待确认。
|
||||||
|
- 读写分离:检索、分析、生成与写入必须分层,禁止一条链路混做。
|
||||||
|
- 结果可审计:所有关键输出必须保留来源、时间、操作者、模型与路由信息。
|
||||||
|
- 不确定显式化:模型不能把推断包装成事实,不能把假设写成结论。
|
||||||
|
- 租户隔离:所有事实、反馈、草案、执行记录必须天然支持 tenant 隔离。
|
||||||
|
|
||||||
|
### 8.3 标准工作链
|
||||||
|
|
||||||
|
- Prompt 负责定义角色、结构、口径和输出格式。
|
||||||
|
- RAG 负责优先获取内部事实,再补充外部知识与背景材料。
|
||||||
|
- 外部检索只负责补行业经验、技术最佳实践和公共信息。
|
||||||
|
- LLM 负责在约束内合成结果,不负责凭空创造事实。
|
||||||
|
- Executor 负责真正落库、改状态、发起写操作。
|
||||||
|
- Feedback Center 负责记录偏差、纠偏和复盘,不负责替代事实源。
|
||||||
|
- Knowledge Graph 负责沉淀结构化事实、关系和证据,不负责流程日志。
|
||||||
|
- Task Brief 负责承载方案草案,不负责替代最终任务实体。
|
||||||
|
|
||||||
|
### 8.4 四层对象边界
|
||||||
|
|
||||||
|
- 事实层:项目、任务、信号、功能目录、知识图谱、关系与证据。
|
||||||
|
- 评审层:反馈中心、审核记录、原因码、修订历史、质量统计。
|
||||||
|
- 生成层:需求分析、MVP 建议、方案评审稿、任务包、草案。
|
||||||
|
- 执行层:项目创建、任务创建、更新、删除、信号接入、状态流转。
|
||||||
|
|
||||||
|
### 8.5 生成层约束
|
||||||
|
|
||||||
|
- 所有生成类输出必须至少区分事实、推断、建议、待确认项四类内容。
|
||||||
|
- 生成层可以组织信息、给出建议,但不能直接改变业务状态。
|
||||||
|
- 生成层不能替代事实层,也不能伪装成执行结果。
|
||||||
|
- 生成层的目标是可交付,不是看起来完整。
|
||||||
|
|
||||||
|
### 8.6 执行层约束
|
||||||
|
|
||||||
|
- 所有写操作必须经过受控执行器。
|
||||||
|
- LLM 和 RAG 都不能直接调用数据库写接口或业务写接口。
|
||||||
|
- 写操作必须具备幂等性、审计日志、权限校验、租户隔离与失败回滚能力。
|
||||||
|
- 高风险动作必须走确认门禁,不能直接自动执行。
|
||||||
|
|
||||||
|
### 8.7 冲突处理
|
||||||
|
|
||||||
|
- 内部事实与外部知识冲突时,以内部事实和当前系统约束为准。
|
||||||
|
- 反馈记录与当前执行结果冲突时,以当前执行结果为当前状态,以反馈记录为修正依据。
|
||||||
|
- 任务草案与最终执行结果冲突时,以最终执行结果为准,草案只作为历史版本保留。
|
||||||
|
- 模型输出与证据冲突时,以证据为准,模型输出必须修正。
|
||||||
|
|
||||||
|
### 8.8 新业务接入门槛
|
||||||
|
|
||||||
|
- 新业务必须先回答五个问题:事实源在哪、输出模板在哪、检索范围在哪、执行器在哪、反馈回流在哪。
|
||||||
|
- 新业务必须明确自身属于事实层、评审层、生成层还是执行层。
|
||||||
|
- 新业务必须注册到统一路由、统一 prompt registry 和统一执行门禁。
|
||||||
|
- 新业务如果无法明确这五项,不得进入正式接入流程。
|
||||||
|
|
||||||
|
### 8.9 禁止事项
|
||||||
|
|
||||||
|
- 禁止让 LLM 直接成为写操作入口。
|
||||||
|
- 禁止让 RAG 直接承担执行职责。
|
||||||
|
- 禁止让反馈中心替代事实源。
|
||||||
|
- 禁止让知识图谱承担流程垃圾桶职责。
|
||||||
|
- 禁止让任务包替代最终任务实体。
|
||||||
|
- 禁止在无证据条件下输出确定性结论。
|
||||||
|
- 禁止绕过统一路由、统一审计和统一租户隔离。
|
||||||
|
|
||||||
|
### 8.10 业务语义补充
|
||||||
|
|
||||||
|
- 涉及 `project.create / signal_source.onboard / followup / query vs execute` 的判断,必须由统一语义规划层负责。
|
||||||
|
- 涉及中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 的规则,只能在统一语义规划目录或 prompt catalog 中定义。
|
||||||
|
- 其它模块只允许做清洗、抽取、结构化解析,不得私自定义业务语义。
|
||||||
|
|
||||||
## 9. 新增代码的参考模式
|
## 9. 新增代码的参考模式
|
||||||
|
|
||||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
|
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
|
||||||
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
||||||
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||||
|
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
|
||||||
|
|
||||||
## 2. 项目定位
|
## 2. 项目定位
|
||||||
- 项目名:`PMFlow / Neural Sync`
|
- 项目名:`PMFlow / Neural Sync`
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,27 @@ from .shared import *
|
||||||
from .helpers import _parse_related_users, _utc_now_naive
|
from .helpers import _parse_related_users, _utc_now_naive
|
||||||
from .schemas import CognitiveConflictResponse, ConflictDetectRequest
|
from .schemas import CognitiveConflictResponse, ConflictDetectRequest
|
||||||
|
|
||||||
|
CONFLICT_TYPE_LABELS = {
|
||||||
|
"skill_mismatch": "技能错配",
|
||||||
|
"quality_drop": "质量下降预警",
|
||||||
|
"schedule_delay": "进度延期预警",
|
||||||
|
"unknown": "待识别冲突",
|
||||||
|
"risk": "风险预警",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _display_conflict_type(value: str | None) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return "待识别冲突"
|
||||||
|
mapped = CONFLICT_TYPE_LABELS.get(raw.lower())
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
if any(separator in raw for separator in ("_", ":", "/", "-", ".")):
|
||||||
|
return "其他冲突"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse])
|
@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse])
|
||||||
async def get_cognitive_conflicts(
|
async def get_cognitive_conflicts(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|
@ -41,6 +62,7 @@ async def get_cognitive_conflicts(
|
||||||
CognitiveConflictResponse(
|
CognitiveConflictResponse(
|
||||||
id=str(c.id),
|
id=str(c.id),
|
||||||
type=c.conflict_type,
|
type=c.conflict_type,
|
||||||
|
display_type=_display_conflict_type(c.conflict_type),
|
||||||
description=c.description,
|
description=c.description,
|
||||||
severity="high" if "critical" in (c.description or "").lower() else "medium",
|
severity="high" if "critical" in (c.description or "").lower() else "medium",
|
||||||
related_users=_parse_related_users(c.involved_user_ids),
|
related_users=_parse_related_users(c.involved_user_ids),
|
||||||
|
|
@ -250,4 +272,3 @@ async def check_task_charter_mismatch(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,27 @@ class ProjectFromTextRequest(BaseModel):
|
||||||
max_length=500
|
max_length=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ProjectFromTextSignalSource(BaseModel):
|
||||||
|
registry_id: int | str | None = None
|
||||||
|
id: int | str | None = None
|
||||||
|
project_id: int | str | None = None
|
||||||
|
signal_id: int | str | None = None
|
||||||
|
source_kind: str
|
||||||
|
source_name: str
|
||||||
|
source_identifier: str
|
||||||
|
display_source_kind: Optional[str] = None
|
||||||
|
technical_identifier: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
requested_via: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
onboarding_status: Optional[str] = None
|
||||||
|
section: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
origin: Optional[str] = None
|
||||||
|
raw_context: Optional[Dict[str, Any]] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
class ProjectFromTextResponse(BaseModel):
|
class ProjectFromTextResponse(BaseModel):
|
||||||
"""零配置项目创建响应"""
|
"""零配置项目创建响应"""
|
||||||
project: Dict[str, Any]
|
project: Dict[str, Any]
|
||||||
|
|
@ -18,13 +39,14 @@ class ProjectFromTextResponse(BaseModel):
|
||||||
charter: Dict[str, Any]
|
charter: Dict[str, Any]
|
||||||
tasks: List[Dict[str, Any]]
|
tasks: List[Dict[str, Any]]
|
||||||
risks: List[Dict[str, Any]]
|
risks: List[Dict[str, Any]]
|
||||||
signal_sources: List[Dict[str, Any]] = Field(default_factory=list)
|
signal_sources: List[ProjectFromTextSignalSource] = Field(default_factory=list)
|
||||||
analysis: Dict[str, Any]
|
analysis: Dict[str, Any]
|
||||||
|
|
||||||
class CognitiveConflictResponse(BaseModel):
|
class CognitiveConflictResponse(BaseModel):
|
||||||
"""认知冲突响应"""
|
"""认知冲突响应"""
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
|
display_type: str
|
||||||
description: str
|
description: str
|
||||||
severity: str
|
severity: str
|
||||||
resolution_suggestion: Optional[str] = None
|
resolution_suggestion: Optional[str] = None
|
||||||
|
|
@ -114,6 +136,10 @@ class ExecutiveBriefingMetric(BaseModel):
|
||||||
class ExecutiveBriefingItem(BaseModel):
|
class ExecutiveBriefingItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
|
display_title: str
|
||||||
|
display_subtitle: str
|
||||||
|
display_source: str
|
||||||
|
technical_identifier: Optional[str] = None
|
||||||
detail: str
|
detail: str
|
||||||
detail_lines: List[str] = Field(default_factory=list)
|
detail_lines: List[str] = Field(default_factory=list)
|
||||||
meta: List[Dict[str, str]] = Field(default_factory=list)
|
meta: List[Dict[str, str]] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,60 @@ from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
PROJECT_SIGNAL_SOURCE_KIND_LABELS = {
|
||||||
|
"custom": "自定义来源",
|
||||||
|
"github": "GitHub 仓库",
|
||||||
|
"feishu": "飞书来源",
|
||||||
|
"wecom": "企业微信来源",
|
||||||
|
"webhook": "Webhook 来源",
|
||||||
|
"browser": "浏览器来源",
|
||||||
|
"manual": "人工录入",
|
||||||
|
"scheduled": "定时来源",
|
||||||
|
"url": "网页来源",
|
||||||
|
"user_insight": "用户洞察来源",
|
||||||
|
"social_media": "社媒来源",
|
||||||
|
"data_analysis": "数据分析来源",
|
||||||
|
"live_gift_analytics": "直播礼物分析来源",
|
||||||
|
"market": "市场行情来源",
|
||||||
|
"policy": "政策监测来源",
|
||||||
|
"long": "长连接来源",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT_SIGNAL_SOURCE_KIND_RE = re.compile(r"^[a-z0-9][a-z0-9:._/-]*[a-z0-9]$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_technical_identifier(value: Any) -> bool:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw or " " in raw or any("\u4e00" <= char <= "\u9fff" for char in raw):
|
||||||
|
return False
|
||||||
|
if not PROJECT_SIGNAL_SOURCE_KIND_RE.match(raw):
|
||||||
|
return False
|
||||||
|
return any(separator in raw for separator in ("_", ":", "/", "-", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def _display_source_kind(value: Any) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return "未分类来源"
|
||||||
|
mapped = PROJECT_SIGNAL_SOURCE_KIND_LABELS.get(raw.lower())
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
if _looks_like_technical_identifier(raw):
|
||||||
|
return "其他来源"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_signal_sources(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
enriched: list[dict[str, Any]] = []
|
||||||
|
for item in items:
|
||||||
|
cloned = dict(item)
|
||||||
|
technical_identifier = str(cloned.get("source_identifier") or "").strip() or None
|
||||||
|
cloned["display_source_kind"] = _display_source_kind(cloned.get("source_kind"))
|
||||||
|
cloned["technical_identifier"] = technical_identifier
|
||||||
|
enriched.append(cloned)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
class AIStewardCreationPersistMixin:
|
class AIStewardCreationPersistMixin:
|
||||||
async def _normalize_project_creation_draft(
|
async def _normalize_project_creation_draft(
|
||||||
self,
|
self,
|
||||||
|
|
@ -183,6 +237,7 @@ class AIStewardCreationPersistMixin:
|
||||||
requirement_items=normalized["analysis"].get("requirement_items") if isinstance(normalized["analysis"].get("requirement_items"), list) else [],
|
requirement_items=normalized["analysis"].get("requirement_items") if isinstance(normalized["analysis"].get("requirement_items"), list) else [],
|
||||||
default_source_kind="custom",
|
default_source_kind="custom",
|
||||||
)
|
)
|
||||||
|
signal_sources = _enrich_signal_sources(signal_sources)
|
||||||
|
|
||||||
charter_data = normalized["charter"]
|
charter_data = normalized["charter"]
|
||||||
charter = TruthNode(
|
charter = TruthNode(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
@ -74,7 +75,36 @@ SOURCE_KIND_LABELS = {
|
||||||
"scheduled": "定时来源",
|
"scheduled": "定时来源",
|
||||||
"manual": "人工录入",
|
"manual": "人工录入",
|
||||||
"feishu": "飞书来源",
|
"feishu": "飞书来源",
|
||||||
|
"wecom": "企业微信来源",
|
||||||
|
"github": "GitHub 仓库",
|
||||||
|
"browser": "浏览器来源",
|
||||||
|
"url": "网页来源",
|
||||||
|
"custom": "自定义来源",
|
||||||
|
"user_insight": "用户洞察来源",
|
||||||
|
"social_media": "社媒来源",
|
||||||
|
"data_analysis": "数据分析来源",
|
||||||
|
"live_gift_analytics": "直播礼物分析来源",
|
||||||
|
"market": "市场行情来源",
|
||||||
|
"policy": "政策监测来源",
|
||||||
|
"long": "长连接来源",
|
||||||
}
|
}
|
||||||
|
CONFLICT_TYPE_LABELS = {
|
||||||
|
"skill_mismatch": "技能错配",
|
||||||
|
"quality_drop": "质量下降预警",
|
||||||
|
"schedule_delay": "进度延期预警",
|
||||||
|
"risk": "风险预警",
|
||||||
|
"unknown": "待识别冲突",
|
||||||
|
}
|
||||||
|
BRIEFING_KIND_LABELS = {
|
||||||
|
"signal": "项目信号",
|
||||||
|
"signal_source": "信号源",
|
||||||
|
"capacity": "人员负载",
|
||||||
|
"task": "项目任务",
|
||||||
|
"notification": "项目风险",
|
||||||
|
"conflict": "协作冲突",
|
||||||
|
"briefing": "事项",
|
||||||
|
}
|
||||||
|
TECHNICAL_IDENTIFIER_RE = re.compile(r"^[a-z0-9][a-z0-9:._/-]*[a-z0-9]$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _deepcopy_dict(value: Any) -> dict[str, Any]:
|
def _deepcopy_dict(value: Any) -> dict[str, Any]:
|
||||||
|
|
@ -98,6 +128,19 @@ def _safe_text(value: Any, *, fallback: str = "-") -> str:
|
||||||
return text or fallback
|
return text or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_cjk(text: str) -> bool:
|
||||||
|
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_technical_identifier(value: Any) -> bool:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text or _contains_cjk(text) or " " in text:
|
||||||
|
return False
|
||||||
|
if not TECHNICAL_IDENTIFIER_RE.match(text):
|
||||||
|
return False
|
||||||
|
return any(separator in text for separator in ("_", ":", "/", "-", "."))
|
||||||
|
|
||||||
|
|
||||||
def _iso_or_none(value: Optional[datetime]) -> Optional[str]:
|
def _iso_or_none(value: Optional[datetime]) -> Optional[str]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -122,7 +165,24 @@ def _label_source_kind(value: Any) -> str:
|
||||||
raw = str(value or "").strip()
|
raw = str(value or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return "-"
|
return "-"
|
||||||
return SOURCE_KIND_LABELS.get(raw.lower(), raw)
|
mapped = SOURCE_KIND_LABELS.get(raw.lower())
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
if _looks_like_technical_identifier(raw):
|
||||||
|
return "其他来源"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _label_conflict_type(value: Any) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return "待识别冲突"
|
||||||
|
mapped = CONFLICT_TYPE_LABELS.get(raw.lower())
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
if _looks_like_technical_identifier(raw):
|
||||||
|
return "其他冲突"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def _target(
|
def _target(
|
||||||
|
|
@ -252,6 +312,12 @@ class ExecutiveBriefingService:
|
||||||
return False
|
return False
|
||||||
if not str(item.get("id") or "").strip():
|
if not str(item.get("id") or "").strip():
|
||||||
return False
|
return False
|
||||||
|
if not str(item.get("display_title") or "").strip():
|
||||||
|
return False
|
||||||
|
if not str(item.get("display_subtitle") or "").strip():
|
||||||
|
return False
|
||||||
|
if not str(item.get("display_source") or "").strip():
|
||||||
|
return False
|
||||||
if not isinstance(item.get("detail_lines"), list):
|
if not isinstance(item.get("detail_lines"), list):
|
||||||
return False
|
return False
|
||||||
if not isinstance(item.get("meta"), list):
|
if not isinstance(item.get("meta"), list):
|
||||||
|
|
@ -479,7 +545,10 @@ class ExecutiveBriefingService:
|
||||||
for item in items[:3]:
|
for item in items[:3]:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
lines.append(f"- {_safe_text(item.get('title'))}:{_safe_text(item.get('detail'))}")
|
lines.append(
|
||||||
|
f"- {_safe_text(item.get('display_title') or item.get('title'))}"
|
||||||
|
f":{_safe_text(item.get('display_subtitle') or item.get('detail'))}"
|
||||||
|
)
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
async def _get_config_row(self) -> Optional[Integration]:
|
async def _get_config_row(self) -> Optional[Integration]:
|
||||||
|
|
@ -600,11 +669,16 @@ class ExecutiveBriefingService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pick_display_text(*values: Any) -> Optional[str]:
|
def _pick_display_text(*values: Any) -> Optional[str]:
|
||||||
|
fallback_text: Optional[str] = None
|
||||||
for value in values:
|
for value in values:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if text:
|
if not text:
|
||||||
|
continue
|
||||||
|
if fallback_text is None:
|
||||||
|
fallback_text = text
|
||||||
|
if not _looks_like_technical_identifier(text):
|
||||||
return text
|
return text
|
||||||
return None
|
return fallback_text
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _is_reportable_signal(cls, *, project_id: Optional[int], title: Optional[str]) -> bool:
|
def _is_reportable_signal(cls, *, project_id: Optional[int], title: Optional[str]) -> bool:
|
||||||
|
|
@ -651,6 +725,18 @@ class ExecutiveBriefingService:
|
||||||
entries.append({"label": label_text, "value": value_text})
|
entries.append({"label": label_text, "value": value_text})
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_subtitle(*values: Any) -> str:
|
||||||
|
for value in values:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text and not _looks_like_technical_identifier(text):
|
||||||
|
return text
|
||||||
|
for value in values:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return "-"
|
||||||
|
|
||||||
async def _get_project_name(self, project_id: Optional[int]) -> Optional[str]:
|
async def _get_project_name(self, project_id: Optional[int]) -> Optional[str]:
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -710,12 +796,32 @@ class ExecutiveBriefingService:
|
||||||
related_user_ids: Optional[list[int]] = None,
|
related_user_ids: Optional[list[int]] = None,
|
||||||
occurred_at: Optional[datetime] = None,
|
occurred_at: Optional[datetime] = None,
|
||||||
target: Optional[dict[str, Any]] = None,
|
target: Optional[dict[str, Any]] = None,
|
||||||
|
display_title: Optional[str] = None,
|
||||||
|
display_subtitle: Optional[str] = None,
|
||||||
|
display_source: Optional[str] = None,
|
||||||
|
technical_identifier: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
cleaned_detail_lines = [line for line in detail_lines if str(line or "").strip()]
|
cleaned_detail_lines = [line for line in detail_lines if str(line or "").strip()]
|
||||||
cleaned_meta = [entry for entry in (meta or []) if str(entry.get("label") or "").strip() and str(entry.get("value") or "").strip()]
|
cleaned_meta = [entry for entry in (meta or []) if str(entry.get("label") or "").strip() and str(entry.get("value") or "").strip()]
|
||||||
|
safe_title = _safe_text(title, fallback="待处理事项")
|
||||||
|
safe_display_title = _safe_text(display_title or safe_title, fallback="待处理事项")
|
||||||
|
safe_display_subtitle = self._display_subtitle(
|
||||||
|
display_subtitle,
|
||||||
|
detail,
|
||||||
|
*cleaned_detail_lines,
|
||||||
|
)
|
||||||
|
safe_display_source = _safe_text(
|
||||||
|
display_source or BRIEFING_KIND_LABELS.get(str(kind or "").strip().lower(), "事项"),
|
||||||
|
fallback="事项",
|
||||||
|
)
|
||||||
|
safe_technical_identifier = str(technical_identifier or "").strip() or None
|
||||||
return {
|
return {
|
||||||
"id": str(item_id),
|
"id": str(item_id),
|
||||||
"title": _safe_text(title, fallback="待处理事项"),
|
"title": safe_title,
|
||||||
|
"display_title": safe_display_title,
|
||||||
|
"display_subtitle": safe_display_subtitle,
|
||||||
|
"display_source": safe_display_source,
|
||||||
|
"technical_identifier": safe_technical_identifier,
|
||||||
"detail": detail or "|".join(cleaned_detail_lines[:3]) or "-",
|
"detail": detail or "|".join(cleaned_detail_lines[:3]) or "-",
|
||||||
"detail_lines": cleaned_detail_lines,
|
"detail_lines": cleaned_detail_lines,
|
||||||
"meta": cleaned_meta,
|
"meta": cleaned_meta,
|
||||||
|
|
@ -821,6 +927,7 @@ class ExecutiveBriefingService:
|
||||||
if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier):
|
if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier):
|
||||||
continue
|
continue
|
||||||
kind_label = _label_source_kind(row.source_kind)
|
kind_label = _label_source_kind(row.source_kind)
|
||||||
|
status_label = _label_status(row.onboarding_status)
|
||||||
items.append(
|
items.append(
|
||||||
await self._build_brief_item(
|
await self._build_brief_item(
|
||||||
item_id=f"signal_source:{row.id}",
|
item_id=f"signal_source:{row.id}",
|
||||||
|
|
@ -829,19 +936,25 @@ class ExecutiveBriefingService:
|
||||||
kind="signal_source",
|
kind="signal_source",
|
||||||
detail_lines=self._detail_lines(
|
detail_lines=self._detail_lines(
|
||||||
kind_label,
|
kind_label,
|
||||||
_safe_text(row.source_identifier),
|
f"接入状态 {status_label}",
|
||||||
f"状态 {_label_status(row.onboarding_status)}",
|
_safe_text(row.description, fallback=""),
|
||||||
),
|
),
|
||||||
meta=self._meta_entries(
|
meta=self._meta_entries(
|
||||||
("来源类型", kind_label),
|
("来源类型", kind_label),
|
||||||
("标识", _safe_text(row.source_identifier)),
|
("接入状态", status_label),
|
||||||
("接入状态", _label_status(row.onboarding_status)),
|
|
||||||
("负责人", await self._get_user_name(user_id)),
|
("负责人", await self._get_user_name(user_id)),
|
||||||
("项目", await self._get_project_name(project_id)),
|
("项目", await self._get_project_name(project_id)),
|
||||||
),
|
),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
occurred_at=row.created_at,
|
occurred_at=row.created_at,
|
||||||
|
display_title=_safe_text(row.source_name, fallback=title_text or "未命名信号源"),
|
||||||
|
display_subtitle=self._display_subtitle(
|
||||||
|
f"{kind_label} · 接入状态 {status_label}",
|
||||||
|
row.description,
|
||||||
|
),
|
||||||
|
display_source=kind_label,
|
||||||
|
technical_identifier=str(row.source_identifier or "").strip() or None,
|
||||||
target=self._project_target(
|
target=self._project_target(
|
||||||
project_id,
|
project_id,
|
||||||
params={
|
params={
|
||||||
|
|
@ -1132,6 +1245,10 @@ class ExecutiveBriefingService:
|
||||||
project_id=normalized_project_id,
|
project_id=normalized_project_id,
|
||||||
related_user_ids=related_user_ids,
|
related_user_ids=related_user_ids,
|
||||||
occurred_at=row.created_at,
|
occurred_at=row.created_at,
|
||||||
|
display_title=_label_conflict_type(row.conflict_type),
|
||||||
|
display_subtitle=self._display_subtitle(row.description),
|
||||||
|
display_source="协作冲突",
|
||||||
|
technical_identifier=str(row.conflict_type or "").strip() if _looks_like_technical_identifier(row.conflict_type) else None,
|
||||||
target=self._project_target(
|
target=self._project_target(
|
||||||
normalized_project_id,
|
normalized_project_id,
|
||||||
params={"stage": "signal"},
|
params={"stage": "signal"},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.api.ai_steward_api import routes_conflicts
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.dependencies.auth import get_current_user
|
||||||
|
from app.dependencies.tenant import get_tenant_id
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.cognitive_conflict import CognitiveConflict
|
||||||
|
from app.models.truth_node import TruthNode
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _use_sqlite_json_types() -> None:
|
||||||
|
User.__table__.c.assigned_domains.type = JSON()
|
||||||
|
TruthNode.__table__.c.content.type = JSON()
|
||||||
|
CognitiveConflict.__table__.c.involved_user_ids.type = JSON()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
_use_sqlite_json_types()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
sync_conn,
|
||||||
|
tables=[
|
||||||
|
User.__table__,
|
||||||
|
TruthNode.__table__,
|
||||||
|
CognitiveConflict.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with maker() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(*, db_session) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes_conflicts.router)
|
||||||
|
|
||||||
|
async def _fake_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _fake_get_db
|
||||||
|
app.dependency_overrides[get_tenant_id] = lambda: 1
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||||
|
id=9001,
|
||||||
|
tenant_id=1,
|
||||||
|
name="管理员",
|
||||||
|
username="admin",
|
||||||
|
role="ADMIN",
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cognitive_conflicts_return_display_type(db_session) -> None:
|
||||||
|
user = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="tester",
|
||||||
|
email="tester@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="测试用户",
|
||||||
|
role="ADMIN",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
truth_node = TruthNode(
|
||||||
|
tenant_id=1,
|
||||||
|
node_type="charter",
|
||||||
|
parent_id=None,
|
||||||
|
project_id=7,
|
||||||
|
signal_id=None,
|
||||||
|
created_by=user.id,
|
||||||
|
title="项目宪章",
|
||||||
|
content={},
|
||||||
|
version=1,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db_session.add(truth_node)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
CognitiveConflict(
|
||||||
|
tenant_id=1,
|
||||||
|
truth_node_id=truth_node.id,
|
||||||
|
conflict_type="skill_mismatch",
|
||||||
|
description="技能要求与成员能力不匹配",
|
||||||
|
involved_user_ids=[user.id],
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
app = _build_app(db_session=db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/ai-steward/cognitive-conflicts")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload[0]["type"] == "skill_mismatch"
|
||||||
|
assert payload[0]["display_type"] == "技能错配"
|
||||||
|
|
@ -7,6 +7,7 @@ from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.api.ai_steward_api import routes_capture
|
from app.api.ai_steward_api import routes_capture
|
||||||
|
from app.api.ai_steward_api.schemas import ProjectFromTextResponse
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.dependencies.auth import get_current_user
|
from app.dependencies.auth import get_current_user
|
||||||
from app.dependencies.tenant import get_tenant_id
|
from app.dependencies.tenant import get_tenant_id
|
||||||
|
|
@ -43,6 +44,8 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
|
||||||
"source_kind": "custom",
|
"source_kind": "custom",
|
||||||
"source_name": "核心痛点 - 内容本地化",
|
"source_name": "核心痛点 - 内容本地化",
|
||||||
"source_identifier": "custom:core-pain-content-localization",
|
"source_identifier": "custom:core-pain-content-localization",
|
||||||
|
"display_source_kind": "自定义来源",
|
||||||
|
"technical_identifier": "custom:core-pain-content-localization",
|
||||||
"description": "翻译公司贵、慢、不地道",
|
"description": "翻译公司贵、慢、不地道",
|
||||||
"section": "核心痛点",
|
"section": "核心痛点",
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +79,16 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
|
||||||
assert payload["message"] == "success"
|
assert payload["message"] == "success"
|
||||||
assert payload["data"]["project"]["id"] == "11"
|
assert payload["data"]["project"]["id"] == "11"
|
||||||
assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化"
|
assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化"
|
||||||
|
assert payload["data"]["signal_sources"][0]["display_source_kind"] == "自定义来源"
|
||||||
|
assert payload["data"]["signal_sources"][0]["technical_identifier"] == "custom:core-pain-content-localization"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_from_text_response_schema_exposes_signal_source_display_fields() -> None:
|
||||||
|
schema = ProjectFromTextResponse.model_json_schema()
|
||||||
|
signal_source = schema["$defs"]["ProjectFromTextSignalSource"]
|
||||||
|
|
||||||
|
assert "display_source_kind" in signal_source["properties"]
|
||||||
|
assert "technical_identifier" in signal_source["properties"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,21 @@ async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, mo
|
||||||
"title": "今日需拍板",
|
"title": "今日需拍板",
|
||||||
"summary": "有 2 项需要拍板",
|
"summary": "有 2 项需要拍板",
|
||||||
"metrics": [{"label": "需拍板", "value": 2, "tone": "warning"}],
|
"metrics": [{"label": "需拍板", "value": 2, "tone": "warning"}],
|
||||||
"items": [],
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "conflict:1",
|
||||||
|
"title": "skill_mismatch",
|
||||||
|
"display_title": "技能错配",
|
||||||
|
"display_subtitle": "技能要求与成员能力不匹配",
|
||||||
|
"display_source": "协作冲突",
|
||||||
|
"technical_identifier": "skill_mismatch",
|
||||||
|
"detail": "技能要求与成员能力不匹配",
|
||||||
|
"detail_lines": ["技能要求与成员能力不匹配"],
|
||||||
|
"meta": [],
|
||||||
|
"tone": "warning",
|
||||||
|
"kind": "conflict",
|
||||||
|
}
|
||||||
|
],
|
||||||
"drilldown_tab": "signals",
|
"drilldown_tab": "signals",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -145,6 +159,10 @@ async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, mo
|
||||||
assert live_payload["code"] == 0
|
assert live_payload["code"] == 0
|
||||||
assert live_payload["data"]["mode"] == "live"
|
assert live_payload["data"]["mode"] == "live"
|
||||||
assert live_payload["data"]["sections"][0]["key"] == "decisions"
|
assert live_payload["data"]["sections"][0]["key"] == "decisions"
|
||||||
|
assert live_payload["data"]["sections"][0]["items"][0]["display_title"] == "技能错配"
|
||||||
|
assert live_payload["data"]["sections"][0]["items"][0]["display_subtitle"] == "技能要求与成员能力不匹配"
|
||||||
|
assert live_payload["data"]["sections"][0]["items"][0]["display_source"] == "协作冲突"
|
||||||
|
assert live_payload["data"]["sections"][0]["items"][0]["technical_identifier"] == "skill_mismatch"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -268,6 +286,84 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses
|
||||||
assert "P0 信号无人接" in text
|
assert "P0 信号无人接" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_pending_signal_sources_include_display_fields(db_session) -> None:
|
||||||
|
owner = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="pm",
|
||||||
|
email="pm@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="产品经理",
|
||||||
|
role="ADMIN",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(owner)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
tenant_id=1,
|
||||||
|
name="cashflow",
|
||||||
|
title="现金流看板",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
SignalSourceRegistry(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=project.id,
|
||||||
|
created_by_user_id=owner.id,
|
||||||
|
source_kind="github",
|
||||||
|
source_name="现金流日报仓库",
|
||||||
|
source_identifier="daily_cashflow_analysis",
|
||||||
|
description="每日现金流分析脚本仓库",
|
||||||
|
requested_via="ai_steward",
|
||||||
|
status="pending",
|
||||||
|
onboarding_status="pending",
|
||||||
|
config={},
|
||||||
|
raw_context={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
service = ExecutiveBriefingService(db_session, tenant_id=1)
|
||||||
|
items = await service._list_pending_signal_sources(limit=5)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["display_title"] == "现金流日报仓库"
|
||||||
|
assert items[0]["display_source"] == "GitHub 仓库"
|
||||||
|
assert items[0]["technical_identifier"] == "daily_cashflow_analysis"
|
||||||
|
assert "daily_cashflow_analysis" not in items[0]["display_subtitle"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_payload_without_display_fields_is_not_current() -> None:
|
||||||
|
payload = {
|
||||||
|
"hero": {"title": "简报中心"},
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"key": "decisions",
|
||||||
|
"title": "今日需拍板",
|
||||||
|
"summary": "有 1 项需要拍板",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "conflict:1",
|
||||||
|
"title": "skill_mismatch",
|
||||||
|
"detail": "技能要求与成员能力不匹配",
|
||||||
|
"detail_lines": ["技能要求与成员能力不匹配"],
|
||||||
|
"meta": [],
|
||||||
|
"tone": "warning",
|
||||||
|
"kind": "conflict",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ExecutiveBriefingService._snapshot_payload_is_current(payload) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None:
|
async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None:
|
||||||
owner = User(
|
owner = User(
|
||||||
|
|
@ -623,3 +719,104 @@ async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_sess
|
||||||
assert overload_item["title"] == "杨宇"
|
assert overload_item["title"] == "杨宇"
|
||||||
assert overload_item["detail_lines"][0] == "159% 负载"
|
assert overload_item["detail_lines"][0] == "159% 负载"
|
||||||
assert any(meta["label"] == "负载" and meta["value"] == "159%" for meta in overload_item["meta"])
|
assert any(meta["label"] == "负载" and meta["value"] == "159%" for meta in overload_item["meta"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executive_briefing_localizes_conflict_and_signal_source_codes(db_session) -> None:
|
||||||
|
owner = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="boss",
|
||||||
|
email="boss@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="李雷",
|
||||||
|
role="BOSS",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(owner)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
tenant_id=1,
|
||||||
|
name="cashflow-forecast",
|
||||||
|
title="现金流平台",
|
||||||
|
description="现金流预测与预警",
|
||||||
|
owner_id=owner.id,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
truth_node = TruthNode(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=project.id,
|
||||||
|
created_by=owner.id,
|
||||||
|
node_type="risk",
|
||||||
|
title="风险节点",
|
||||||
|
content={"summary": "风险节点"},
|
||||||
|
version=1,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db_session.add(truth_node)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
SignalSourceRegistry(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=project.id,
|
||||||
|
created_by_user_id=owner.id,
|
||||||
|
source_kind="user_insight",
|
||||||
|
source_name="用户洞察数据源",
|
||||||
|
source_identifier="ai-customer-service-insight",
|
||||||
|
description="用户洞察相关信号源",
|
||||||
|
requested_via="ai_steward",
|
||||||
|
status="pending",
|
||||||
|
onboarding_status="pending",
|
||||||
|
config={},
|
||||||
|
raw_context={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
CognitiveConflict(
|
||||||
|
tenant_id=1,
|
||||||
|
truth_node_id=truth_node.id,
|
||||||
|
conflict_type="quality_drop",
|
||||||
|
description="质量下降预警",
|
||||||
|
involved_user_ids=[],
|
||||||
|
status="active",
|
||||||
|
),
|
||||||
|
CognitiveConflict(
|
||||||
|
tenant_id=1,
|
||||||
|
truth_node_id=truth_node.id,
|
||||||
|
conflict_type="schedule_delay",
|
||||||
|
description="任务超时预警",
|
||||||
|
involved_user_ids=[],
|
||||||
|
status="active",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
service = ExecutiveBriefingService(db_session, tenant_id=1)
|
||||||
|
payload = await service.build_brief_payload(
|
||||||
|
mode="live",
|
||||||
|
baseline_generated_at=None,
|
||||||
|
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
|
||||||
|
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
|
||||||
|
|
||||||
|
source_item = next(item for item in input_health["items"] if item["kind"] == "signal_source")
|
||||||
|
assert source_item["display_source"] == "用户洞察来源"
|
||||||
|
assert source_item["display_subtitle"].startswith("用户洞察来源")
|
||||||
|
assert "user_insight" not in source_item["display_subtitle"]
|
||||||
|
|
||||||
|
conflict_titles = {
|
||||||
|
item["technical_identifier"]: item["display_title"]
|
||||||
|
for item in decisions["items"]
|
||||||
|
if item["kind"] == "conflict"
|
||||||
|
}
|
||||||
|
assert conflict_titles["quality_drop"] == "质量下降预警"
|
||||||
|
assert conflict_titles["schedule_delay"] == "进度延期预警"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export interface ProjectFromTextSignalSource {
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
source_name: string;
|
source_name: string;
|
||||||
source_identifier: string;
|
source_identifier: string;
|
||||||
|
display_source_kind?: string | null;
|
||||||
|
technical_identifier?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
requested_via?: string;
|
requested_via?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -89,6 +91,7 @@ export interface SignalSourceRepairResponse {
|
||||||
export interface CognitiveConflict {
|
export interface CognitiveConflict {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
display_type: string;
|
||||||
description: string;
|
description: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
related_users: string[];
|
related_users: string[];
|
||||||
|
|
@ -135,6 +138,10 @@ export interface BriefingMetaEntry {
|
||||||
export interface ExecutiveBriefingItem {
|
export interface ExecutiveBriefingItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
display_title: string;
|
||||||
|
display_subtitle: string;
|
||||||
|
display_source: string;
|
||||||
|
technical_identifier?: string | null;
|
||||||
detail: string;
|
detail: string;
|
||||||
detail_lines: string[];
|
detail_lines: string[];
|
||||||
meta: BriefingMetaEntry[];
|
meta: BriefingMetaEntry[];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||||
|
import { toZhKnowledgeDocStatus, toZhKnowledgeFileType, toZhKnowledgeSourceType } from "@/lib/display";
|
||||||
import type { KnowledgeDoc } from "../types";
|
import type { KnowledgeDoc } from "../types";
|
||||||
|
|
||||||
interface KnowledgeTabProps {
|
interface KnowledgeTabProps {
|
||||||
|
|
@ -96,7 +97,7 @@ export default function KnowledgeTab({
|
||||||
添加中…
|
添加中…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Add URL"
|
"添加 URL"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,7 +119,9 @@ export default function KnowledgeTab({
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-1 min-w-0">
|
<div className="space-y-1 min-w-0">
|
||||||
<div className="font-medium truncate">{doc.title}</div>
|
<div className="font-medium truncate">{doc.title}</div>
|
||||||
<div className="text-xs text-slate-500">类型: {doc.file_type} · 来源: {doc.source_type}</div>
|
<div className="text-xs text-slate-500">
|
||||||
|
类型: {toZhKnowledgeFileType(doc.file_type)} · 来源: {toZhKnowledgeSourceType(doc.source_type)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
上传时间: {formatBeijingDateTime(doc.created_at)}
|
上传时间: {formatBeijingDateTime(doc.created_at)}
|
||||||
{doc.last_fetched_at ? ` · 最近抓取: ${formatBeijingDateTime(doc.last_fetched_at)}` : ""}
|
{doc.last_fetched_at ? ` · 最近抓取: ${formatBeijingDateTime(doc.last_fetched_at)}` : ""}
|
||||||
|
|
@ -128,7 +131,7 @@ export default function KnowledgeTab({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Badge variant={doc.status === "complete" ? "default" : doc.status === "failed" ? "destructive" : "secondary"}>
|
<Badge variant={doc.status === "complete" ? "default" : doc.status === "failed" ? "destructive" : "secondary"}>
|
||||||
{doc.status === "processing" ? "processing" : doc.status === "complete" ? "complete" : "failed"}
|
{toZhKnowledgeDocStatus(doc.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{doc.source_type === "url" && (
|
{doc.source_type === "url" && (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { formatTechnicalIdentifierLabel } from "@/lib/display";
|
||||||
import type { AIStewardTab, BriefingTarget, ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
|
import type { AIStewardTab, BriefingTarget, ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
|
||||||
import { groupBriefingItemsByProject } from "./BriefingSectionCard";
|
import {
|
||||||
|
getBriefingItemDisplaySource,
|
||||||
|
getBriefingItemDisplaySubtitle,
|
||||||
|
getBriefingItemDisplayTitle,
|
||||||
|
groupBriefingItemsByProject,
|
||||||
|
} from "./BriefingSectionCard";
|
||||||
|
|
||||||
function toneBadgeClass(tone: string): string {
|
function toneBadgeClass(tone: string): string {
|
||||||
switch (tone) {
|
switch (tone) {
|
||||||
|
|
@ -57,25 +63,6 @@ function buildActionLabel(target?: BriefingTarget | null, fallbackTab?: AIStewar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemKindLabel(kind?: string | null): string {
|
|
||||||
switch (kind) {
|
|
||||||
case "signal":
|
|
||||||
return "项目信号";
|
|
||||||
case "signal_source":
|
|
||||||
return "信号源";
|
|
||||||
case "capacity":
|
|
||||||
return "人员负载";
|
|
||||||
case "task":
|
|
||||||
return "项目任务";
|
|
||||||
case "notification":
|
|
||||||
return "项目风险";
|
|
||||||
case "conflict":
|
|
||||||
return "协作冲突";
|
|
||||||
default:
|
|
||||||
return "事项";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BriefingDetailDrawerProps {
|
interface BriefingDetailDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
section: ExecutiveBriefingSection | null;
|
section: ExecutiveBriefingSection | null;
|
||||||
|
|
@ -92,7 +79,7 @@ function getItemDetailLines(item: ExecutiveBriefingItem): string[] {
|
||||||
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
|
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
|
||||||
return item.detail_lines;
|
return item.detail_lines;
|
||||||
}
|
}
|
||||||
const detail = String(item.detail || "").trim();
|
const detail = getBriefingItemDisplaySubtitle(item);
|
||||||
return detail ? [detail] : [];
|
return detail ? [detail] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +97,9 @@ function getItemMeta(item: ExecutiveBriefingItem): Array<{ label: string; value:
|
||||||
if (item.occurred_at) {
|
if (item.occurred_at) {
|
||||||
fallback.push({ label: "时间", value: item.occurred_at });
|
fallback.push({ label: "时间", value: item.occurred_at });
|
||||||
}
|
}
|
||||||
|
if (item.display_source) {
|
||||||
|
fallback.push({ label: "来源", value: item.display_source });
|
||||||
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,13 +295,17 @@ export default function BriefingDetailDrawer({
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-medium text-slate-950 dark:text-slate-50">{item.title}</span>
|
<span className="font-medium text-slate-950 dark:text-slate-50">
|
||||||
|
{getBriefingItemDisplayTitle(item)}
|
||||||
|
</span>
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
||||||
{itemKindLabel(item.kind)}
|
{getBriefingItemDisplaySource(item)}
|
||||||
</span>
|
</span>
|
||||||
<Badge className={toneBadgeClass(item.tone)}>{toneLabel(item.tone)}</Badge>
|
<Badge className={toneBadgeClass(item.tone)}>{toneLabel(item.tone)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{item.detail}</p>
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
|
{getBriefingItemDisplaySubtitle(item)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{expandedItem ? (
|
{expandedItem ? (
|
||||||
<ChevronDown className="mt-1 h-4 w-4 shrink-0 text-slate-500" />
|
<ChevronDown className="mt-1 h-4 w-4 shrink-0 text-slate-500" />
|
||||||
|
|
@ -343,6 +337,11 @@ export default function BriefingDetailDrawer({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{item.technical_identifier && (
|
||||||
|
<div className="mt-4 rounded-xl border border-slate-200/80 bg-white px-3 py-3 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
{formatTechnicalIdentifierLabel(item.technical_identifier)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{renderActionButtons(item, runtimeKey)}
|
{renderActionButtons(item, runtimeKey)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import type {
|
||||||
ExecutiveBriefingHero,
|
ExecutiveBriefingHero,
|
||||||
ExecutiveBriefingSection,
|
ExecutiveBriefingSection,
|
||||||
} from "@/pages/ai-steward/types";
|
} from "@/pages/ai-steward/types";
|
||||||
import { groupBriefingItemsByProject } from "./BriefingSectionCard";
|
import { formatTechnicalIdentifierLabel } from "@/lib/display";
|
||||||
|
import {
|
||||||
|
getBriefingItemDisplaySource,
|
||||||
|
getBriefingItemDisplaySubtitle,
|
||||||
|
getBriefingItemDisplayTitle,
|
||||||
|
groupBriefingItemsByProject,
|
||||||
|
} from "./BriefingSectionCard";
|
||||||
|
|
||||||
export interface PrintableBriefingPayload {
|
export interface PrintableBriefingPayload {
|
||||||
brief_date: string;
|
brief_date: string;
|
||||||
|
|
@ -40,25 +46,6 @@ function toneLabel(tone: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemKindLabel(kind?: string | null): string {
|
|
||||||
switch (kind) {
|
|
||||||
case "signal":
|
|
||||||
return "项目信号";
|
|
||||||
case "signal_source":
|
|
||||||
return "信号源";
|
|
||||||
case "capacity":
|
|
||||||
return "人员负载";
|
|
||||||
case "task":
|
|
||||||
return "项目任务";
|
|
||||||
case "notification":
|
|
||||||
return "项目风险";
|
|
||||||
case "conflict":
|
|
||||||
return "协作冲突";
|
|
||||||
default:
|
|
||||||
return "事项";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeLabel(mode: PrintableBriefingPayload["mode"]): string {
|
function modeLabel(mode: PrintableBriefingPayload["mode"]): string {
|
||||||
return mode === "live" ? "实时版" : "晨报快照";
|
return mode === "live" ? "实时版" : "晨报快照";
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +105,9 @@ function renderItemMeta(item: ExecutiveBriefingSection["items"][number]): Array<
|
||||||
return item.meta;
|
return item.meta;
|
||||||
}
|
}
|
||||||
const fallback: Array<{ label: string; value: string }> = [];
|
const fallback: Array<{ label: string; value: string }> = [];
|
||||||
|
if (item.display_source) {
|
||||||
|
fallback.push({ label: "来源", value: item.display_source });
|
||||||
|
}
|
||||||
if (item.project_name) {
|
if (item.project_name) {
|
||||||
fallback.push({ label: "项目", value: item.project_name });
|
fallback.push({ label: "项目", value: item.project_name });
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +124,7 @@ function renderItemDetailLines(item: ExecutiveBriefingSection["items"][number]):
|
||||||
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
|
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
|
||||||
return item.detail_lines;
|
return item.detail_lines;
|
||||||
}
|
}
|
||||||
const detail = String(item.detail || "").trim();
|
const detail = getBriefingItemDisplaySubtitle(item);
|
||||||
return detail ? [detail] : [];
|
return detail ? [detail] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,14 +153,14 @@ function renderItems(section: ExecutiveBriefingSection): string {
|
||||||
const detailLines = renderItemDetailLines(item);
|
const detailLines = renderItemDetailLines(item);
|
||||||
return `
|
return `
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<div class="item-head">
|
<div class="item-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="item-title">${escapeHtml(item.title)}</span>
|
<span class="item-title">${escapeHtml(getBriefingItemDisplayTitle(item))}</span>
|
||||||
<span class="item-kind">${escapeHtml(itemKindLabel(item.kind))}</span>
|
<span class="item-kind">${escapeHtml(getBriefingItemDisplaySource(item))}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="tone tone-${escapeHtml(item.tone)}">${toneLabel(item.tone)}</span>
|
<span class="tone tone-${escapeHtml(item.tone)}">${toneLabel(item.tone)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-detail">${escapeHtml(item.detail)}</div>
|
<div class="item-detail">${escapeHtml(getBriefingItemDisplaySubtitle(item))}</div>
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
${meta
|
${meta
|
||||||
.map(
|
.map(
|
||||||
|
|
@ -183,6 +173,7 @@ function renderItems(section: ExecutiveBriefingSection): string {
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
|
${item.technical_identifier ? `<div class="action-tag">${escapeHtml(formatTechnicalIdentifierLabel(item.technical_identifier))}</div>` : ""}
|
||||||
<div class="detail-lines">
|
<div class="detail-lines">
|
||||||
${detailLines
|
${detailLines
|
||||||
.map((line) => `<div class="detail-line">${escapeHtml(line)}</div>`)
|
.map((line) => `<div class="detail-line">${escapeHtml(line)}</div>`)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import { ChevronDown, ChevronRight, PanelRightOpen } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
shouldHideTechnicalIdentifierFromPrimaryText,
|
||||||
|
toZhConflictType,
|
||||||
|
toZhSourceKind,
|
||||||
|
} from "@/lib/display";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
|
import type { ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
|
||||||
|
|
||||||
|
|
@ -71,6 +76,91 @@ function getMetaValue(item: ExecutiveBriefingItem, label: string): string | null
|
||||||
return value || null;
|
return value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(value?: string | null): string {
|
||||||
|
return String(value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeSignalSourceLabel(value?: string | null): string {
|
||||||
|
const raw = normalizeText(value);
|
||||||
|
if (!raw) return "信号源";
|
||||||
|
const localized = toZhSourceKind(raw);
|
||||||
|
if (shouldHideTechnicalIdentifierFromPrimaryText(localized)) {
|
||||||
|
return "其他来源";
|
||||||
|
}
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSignalSourceSubtitle(item: ExecutiveBriefingItem, rawSubtitle: string): string {
|
||||||
|
const segments = rawSubtitle
|
||||||
|
.split(/[||]/)
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!segments.length) return rawSubtitle;
|
||||||
|
|
||||||
|
const technicalIdentifier = normalizeText(item.technical_identifier);
|
||||||
|
const sanitized = segments
|
||||||
|
.filter((segment) => {
|
||||||
|
if (technicalIdentifier && segment === technicalIdentifier) return false;
|
||||||
|
return !shouldHideTechnicalIdentifierFromPrimaryText(segment);
|
||||||
|
})
|
||||||
|
.map((segment, index) => (index === 0 ? localizeSignalSourceLabel(segment) : segment));
|
||||||
|
|
||||||
|
return sanitized.join("|") || rawSubtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBriefingItemDisplayTitle(item: ExecutiveBriefingItem): string {
|
||||||
|
const displayTitle = normalizeText(item.display_title);
|
||||||
|
const rawTitle = normalizeText(item.title);
|
||||||
|
|
||||||
|
if (item.kind === "conflict") {
|
||||||
|
const localized = toZhConflictType(displayTitle || item.technical_identifier || rawTitle);
|
||||||
|
if (localized !== "-") {
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayTitle && !shouldHideTechnicalIdentifierFromPrimaryText(displayTitle)) {
|
||||||
|
return displayTitle;
|
||||||
|
}
|
||||||
|
if (rawTitle && !shouldHideTechnicalIdentifierFromPrimaryText(rawTitle)) {
|
||||||
|
return rawTitle;
|
||||||
|
}
|
||||||
|
if (item.kind === "signal_source") {
|
||||||
|
return "未命名信号源";
|
||||||
|
}
|
||||||
|
return displayTitle || rawTitle || "其他事项";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBriefingItemDisplaySubtitle(item: ExecutiveBriefingItem): string {
|
||||||
|
const subtitle = normalizeText(item.display_subtitle) || normalizeText(item.detail);
|
||||||
|
if (!subtitle) return "展开后查看详细内容";
|
||||||
|
if (item.kind === "signal_source") {
|
||||||
|
return sanitizeSignalSourceSubtitle(item, subtitle);
|
||||||
|
}
|
||||||
|
if (item.kind === "conflict" && shouldHideTechnicalIdentifierFromPrimaryText(subtitle)) {
|
||||||
|
const localized = toZhConflictType(item.technical_identifier || subtitle);
|
||||||
|
if (localized !== "-") {
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBriefingItemDisplaySource(item: ExecutiveBriefingItem): string {
|
||||||
|
const displaySource = normalizeText(item.display_source);
|
||||||
|
if (item.kind === "signal_source") {
|
||||||
|
return localizeSignalSourceLabel(displaySource || item.technical_identifier);
|
||||||
|
}
|
||||||
|
if (item.kind === "conflict") {
|
||||||
|
return "协作冲突";
|
||||||
|
}
|
||||||
|
if (displaySource && !shouldHideTechnicalIdentifierFromPrimaryText(displaySource)) {
|
||||||
|
return displaySource;
|
||||||
|
}
|
||||||
|
return itemKindLabel(item.kind);
|
||||||
|
}
|
||||||
|
|
||||||
function itemKindLabel(kind?: string | null): string {
|
function itemKindLabel(kind?: string | null): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "signal":
|
case "signal":
|
||||||
|
|
@ -96,14 +186,15 @@ export function groupBriefingItemsByProject(items: ExecutiveBriefingItem[]): Bri
|
||||||
const projectId = typeof item.project_id === "number" ? item.project_id : null;
|
const projectId = typeof item.project_id === "number" ? item.project_id : null;
|
||||||
const projectName = String(item.project_name || "").trim();
|
const projectName = String(item.project_name || "").trim();
|
||||||
const ownerName = String(item.owner_name || "").trim();
|
const ownerName = String(item.owner_name || "").trim();
|
||||||
const sourceIdentifier = getMetaValue(item, "标识");
|
|
||||||
const workloadValue = getMetaValue(item, "负载");
|
const workloadValue = getMetaValue(item, "负载");
|
||||||
const fallbackTitle = String(item.title || "").trim() || "其他事项";
|
const fallbackTitle = getBriefingItemDisplayTitle(item);
|
||||||
|
const fallbackSubtitle = getBriefingItemDisplaySubtitle(item);
|
||||||
|
const sourceLabel = getBriefingItemDisplaySource(item);
|
||||||
|
|
||||||
let key = "";
|
let key = "";
|
||||||
let title = "";
|
let title = "";
|
||||||
let categoryLabel = "";
|
let categoryLabel = "";
|
||||||
let previewText = String(item.detail || "").trim() || "展开后查看详细内容";
|
let previewText = fallbackSubtitle;
|
||||||
|
|
||||||
if (projectId !== null && projectName) {
|
if (projectId !== null && projectName) {
|
||||||
key = `project:${projectId}`;
|
key = `project:${projectId}`;
|
||||||
|
|
@ -111,18 +202,17 @@ export function groupBriefingItemsByProject(items: ExecutiveBriefingItem[]): Bri
|
||||||
categoryLabel = "项目";
|
categoryLabel = "项目";
|
||||||
} else if (item.kind === "signal_source") {
|
} else if (item.kind === "signal_source") {
|
||||||
key = `signal-source:${item.id}`;
|
key = `signal-source:${item.id}`;
|
||||||
title = sourceIdentifier ? `${fallbackTitle}` : fallbackTitle;
|
title = fallbackTitle;
|
||||||
categoryLabel = "信号源";
|
categoryLabel = sourceLabel;
|
||||||
previewText = sourceIdentifier ? `来源标识 ${sourceIdentifier}` : previewText;
|
|
||||||
} else if (item.kind === "capacity" || item.user_id || ownerName) {
|
} else if (item.kind === "capacity" || item.user_id || ownerName) {
|
||||||
key = `user:${item.user_id ?? item.id}`;
|
key = `user:${item.user_id ?? item.id}`;
|
||||||
title = ownerName || fallbackTitle;
|
title = ownerName || fallbackTitle;
|
||||||
categoryLabel = "成员";
|
categoryLabel = "成员";
|
||||||
previewText = workloadValue ? `${workloadValue}|${String(item.detail || "").trim()}` : previewText;
|
previewText = workloadValue ? `${workloadValue}|${fallbackSubtitle}` : previewText;
|
||||||
} else {
|
} else {
|
||||||
key = `item:${item.id}`;
|
key = `item:${item.id}`;
|
||||||
title = fallbackTitle;
|
title = fallbackTitle;
|
||||||
categoryLabel = itemKindLabel(item.kind);
|
categoryLabel = sourceLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = groups.get(key);
|
const existing = groups.get(key);
|
||||||
|
|
@ -261,7 +351,9 @@ export default function BriefingSectionCard({
|
||||||
<span className="text-xs text-slate-500">{group.items.length} 条</span>
|
<span className="text-xs text-slate-500">{group.items.length} 条</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
{group.previewText || group.items[0]?.detail || "展开后查看详细内容"}
|
{group.previewText ||
|
||||||
|
(group.items[0] ? getBriefingItemDisplaySubtitle(group.items[0]) : "") ||
|
||||||
|
"展开后查看详细内容"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -283,13 +375,15 @@ export default function BriefingSectionCard({
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="font-medium text-slate-950 dark:text-slate-50">{item.title}</p>
|
<p className="font-medium text-slate-950 dark:text-slate-50">
|
||||||
|
{getBriefingItemDisplayTitle(item)}
|
||||||
|
</p>
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
||||||
{itemKindLabel(item.kind)}
|
{getBriefingItemDisplaySource(item)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
<p className="mt-1 line-clamp-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
{item.detail}
|
{getBriefingItemDisplaySubtitle(item)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs text-slate-400">展开查看</span>
|
<span className="shrink-0 text-xs text-slate-400">展开查看</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import type { FeedbackCenterRecord, FeedbackCenterRecordRevision } from "@/api/feedbackCenter";
|
import type { FeedbackCenterRecord, FeedbackCenterRecordRevision } from "@/api/feedbackCenter";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
formatTechnicalIdentifierLabel,
|
||||||
|
toZhFeedbackFieldName,
|
||||||
|
toZhFeedbackReasonCode,
|
||||||
|
toZhFeedbackSourceType,
|
||||||
|
toZhReviewStatus,
|
||||||
|
} from "@/lib/display";
|
||||||
|
|
||||||
interface FeedbackDetailProps {
|
interface FeedbackDetailProps {
|
||||||
record: FeedbackCenterRecord | null;
|
record: FeedbackCenterRecord | null;
|
||||||
|
|
@ -42,16 +49,16 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
|
||||||
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Diff View</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">差异视图</p>
|
||||||
<h3 className="mt-1 text-lg font-semibold text-slate-900">
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
{record.object_title || record.source_title || `记录 #${record.id}`}
|
{record.object_title || record.source_title || `记录 #${record.id}`}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
{record.source_type} · 字段 {record.field_name}
|
{toZhFeedbackSourceType(record.source_type)} · 字段 {toZhFeedbackFieldName(record.field_name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge className="border-slate-200 bg-slate-100 text-slate-700">状态 {record.review_status}</Badge>
|
<Badge className="border-slate-200 bg-slate-100 text-slate-700">状态 {toZhReviewStatus(record.review_status)}</Badge>
|
||||||
{record.auto_assigned && (
|
{record.auto_assigned && (
|
||||||
<Badge className="border-emerald-200 bg-emerald-50 text-emerald-700">AI 自动指派</Badge>
|
<Badge className="border-emerald-200 bg-emerald-50 text-emerald-700">AI 自动指派</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
@ -60,7 +67,7 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Meta</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">基础信息</p>
|
||||||
<div className="mt-3 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
<div className="mt-3 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">项目</p>
|
<p className="text-xs text-slate-500">项目</p>
|
||||||
|
|
@ -72,7 +79,7 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">原因码</p>
|
<p className="text-xs text-slate-500">原因码</p>
|
||||||
<p>{record.reason_code || "—"}</p>
|
<p>{toZhFeedbackReasonCode(record.reason_code)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">置信度</p>
|
<p className="text-xs text-slate-500">置信度</p>
|
||||||
|
|
@ -81,21 +88,30 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">AI Snapshot</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">AI 快照</p>
|
||||||
<pre className="mt-3 max-h-[210px] overflow-auto whitespace-pre-wrap break-words text-xs leading-6 text-slate-700">
|
<pre className="mt-3 max-h-[210px] overflow-auto whitespace-pre-wrap break-words text-xs leading-6 text-slate-700">
|
||||||
{formatJson(record.ai_snapshot)}
|
{formatJson(record.ai_snapshot)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">技术信息</p>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm text-slate-600">
|
||||||
|
<p>{formatTechnicalIdentifierLabel(record.source_type, "技术来源")}</p>
|
||||||
|
{record.reason_code && <p>{formatTechnicalIdentifierLabel(record.reason_code, "技术原因码")}</p>}
|
||||||
|
<p>{formatTechnicalIdentifierLabel(record.field_name, "技术字段")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
{diffCard("AI Before", record.before_value, "before")}
|
{diffCard("AI 原始结果", record.before_value, "before")}
|
||||||
{diffCard("Human After", record.after_value, "after")}
|
{diffCard("人工修正结果", record.after_value, "after")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Revisions</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">修订历史</p>
|
||||||
<span className="text-xs text-slate-500">{revisions.length} 条</span>
|
<span className="text-xs text-slate-500">{revisions.length} 条</span>
|
||||||
</div>
|
</div>
|
||||||
{!revisions.length && <p className="mt-3 text-sm text-slate-500">暂无修订历史。</p>}
|
{!revisions.length && <p className="mt-3 text-sm text-slate-500">暂无修订历史。</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { FeedbackCenterRecord, FeedbackCenterSummaryReason } from "@/api/feedbackCenter";
|
import type { FeedbackCenterRecord, FeedbackCenterSummaryReason } from "@/api/feedbackCenter";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toZhFeedbackFieldName, toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
|
||||||
|
|
||||||
interface FeedbackListProps {
|
interface FeedbackListProps {
|
||||||
items: FeedbackCenterRecord[];
|
items: FeedbackCenterRecord[];
|
||||||
|
|
@ -30,7 +31,7 @@ function sourceLabel(sourceType: string): string {
|
||||||
if (sourceType === "task_breakdown" || sourceType === "task_override") return "任务";
|
if (sourceType === "task_breakdown" || sourceType === "task_override") return "任务";
|
||||||
if (sourceType === "qa_feedback") return "QA";
|
if (sourceType === "qa_feedback") return "QA";
|
||||||
if (sourceType === "signal_feedback") return "信号";
|
if (sourceType === "signal_feedback") return "信号";
|
||||||
return sourceType || "未知";
|
return toZhFeedbackSourceType(sourceType) || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedbackList({
|
export function FeedbackList({
|
||||||
|
|
@ -54,7 +55,7 @@ export function FeedbackList({
|
||||||
<div className="border-b border-slate-100 p-4">
|
<div className="border-b border-slate-100 p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Queue</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">待处理队列</p>
|
||||||
<h3 className="text-lg font-semibold text-slate-900">待复核记录</h3>
|
<h3 className="text-lg font-semibold text-slate-900">待复核记录</h3>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -125,11 +126,11 @@ export function FeedbackList({
|
||||||
{item.object_title || item.source_title || `${sourceLabel(item.source_type)} #${item.object_id}`}
|
{item.object_title || item.source_title || `${sourceLabel(item.source_type)} #${item.object_id}`}
|
||||||
</p>
|
</p>
|
||||||
<p className={`mt-1 text-xs ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
<p className={`mt-1 text-xs ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
||||||
{sourceLabel(item.source_type)} · 字段 {item.field_name}
|
{toZhFeedbackSourceType(item.source_type)} · 字段 {toZhFeedbackFieldName(item.field_name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={selected ? "border-white/20 bg-white/10 text-white" : statusTone(item.review_status)}>
|
<Badge className={selected ? "border-white/20 bg-white/10 text-white" : statusTone(item.review_status)}>
|
||||||
{item.review_status}
|
{toZhReviewStatus(item.review_status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-3 flex flex-wrap gap-2 text-[11px] ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
<div className={`mt-3 flex flex-wrap gap-2 text-[11px] ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { FeedbackCenterSummary } from "@/api/feedbackCenter";
|
import type { FeedbackCenterSummary } from "@/api/feedbackCenter";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
|
||||||
|
|
||||||
interface MetricsPanelProps {
|
interface MetricsPanelProps {
|
||||||
summary?: FeedbackCenterSummary | null;
|
summary?: FeedbackCenterSummary | null;
|
||||||
|
|
@ -19,20 +20,6 @@ const EMPTY_METRICS = {
|
||||||
total_revision_count: 0,
|
total_revision_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
task_breakdown: "任务拆解",
|
|
||||||
task_override: "任务覆盖",
|
|
||||||
qa_feedback: "QA 反馈",
|
|
||||||
signal_feedback: "信号反馈",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
pending: "待复核",
|
|
||||||
reviewed: "已复核",
|
|
||||||
accepted: "已采纳",
|
|
||||||
rejected: "已拒绝",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
return `${Math.round(value * 100)}%`;
|
return `${Math.round(value * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +129,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
|
||||||
|
|
||||||
const sourceRows = Object.entries(bySourceType)
|
const sourceRows = Object.entries(bySourceType)
|
||||||
.map(([label, count]) => ({
|
.map(([label, count]) => ({
|
||||||
label: SOURCE_LABELS[label] || label,
|
label: toZhFeedbackSourceType(label),
|
||||||
count,
|
count,
|
||||||
percent: sourceTotal > 0 ? count / sourceTotal : 0,
|
percent: sourceTotal > 0 ? count / sourceTotal : 0,
|
||||||
}))
|
}))
|
||||||
|
|
@ -150,7 +137,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
|
||||||
|
|
||||||
const statusRows = Object.entries(byStatus)
|
const statusRows = Object.entries(byStatus)
|
||||||
.map(([label, count]) => ({
|
.map(([label, count]) => ({
|
||||||
label: STATUS_LABELS[label] || label,
|
label: toZhReviewStatus(label),
|
||||||
count,
|
count,
|
||||||
percent: statusTotal > 0 ? count / statusTotal : 0,
|
percent: statusTotal > 0 ? count / statusTotal : 0,
|
||||||
}))
|
}))
|
||||||
|
|
@ -192,7 +179,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Learning Health</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">学习健康度</p>
|
||||||
<h3 className="mt-2 text-lg font-semibold text-slate-900">学习健康度</h3>
|
<h3 className="mt-2 text-lg font-semibold text-slate-900">学习健康度</h3>
|
||||||
<p className="mt-1 text-sm text-slate-500">把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。</p>
|
<p className="mt-1 text-sm text-slate-500">把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。</p>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { toZhFeedbackReasonCode, toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
|
||||||
|
|
||||||
interface TrendPanelProps {
|
interface TrendPanelProps {
|
||||||
summary?: FeedbackCenterSummary | null;
|
summary?: FeedbackCenterSummary | null;
|
||||||
|
|
@ -284,7 +285,7 @@ export function FeedbackTrendPanel({
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Trend Scope</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">趋势范围</p>
|
||||||
<h3 className="mt-1 text-lg font-semibold text-slate-900">趋势分析范围</h3>
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">趋势分析范围</h3>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
这里的筛选只影响趋势页,不会改动总览、指标页或任务复核列表。
|
这里的筛选只影响趋势页,不会改动总览、指标页或任务复核列表。
|
||||||
|
|
@ -373,7 +374,7 @@ export function FeedbackTrendPanel({
|
||||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Decision Focus</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">处理重点</p>
|
||||||
<h3 className="mt-1 text-2xl font-semibold text-slate-900">{decisionHeadline}</h3>
|
<h3 className="mt-1 text-2xl font-semibold text-slate-900">{decisionHeadline}</h3>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
这里先告诉你近 {windowDays} 天什么值得处理,再给你一键跳转到对应样本,最后再看趋势图做复盘。
|
这里先告诉你近 {windowDays} 天什么值得处理,再给你一键跳转到对应样本,最后再看趋势图做复盘。
|
||||||
|
|
@ -409,10 +410,13 @@ export function FeedbackTrendPanel({
|
||||||
跳转:{insight.target_section === "task" ? "任务" : insight.target_section === "qa" ? "QA" : insight.target_section === "signal" ? "信号" : "总览"}
|
跳转:{insight.target_section === "task" ? "任务" : insight.target_section === "qa" ? "QA" : insight.target_section === "signal" ? "信号" : "总览"}
|
||||||
</span>
|
</span>
|
||||||
{insight.target_source_type && (
|
{insight.target_source_type && (
|
||||||
<span className="rounded-full bg-white/70 px-2.5 py-1">{insight.target_source_type}</span>
|
<span className="rounded-full bg-white/70 px-2.5 py-1">{toZhFeedbackSourceType(insight.target_source_type)}</span>
|
||||||
|
)}
|
||||||
|
{insight.target_review_status && (
|
||||||
|
<span className="rounded-full bg-white/70 px-2.5 py-1">{toZhReviewStatus(insight.target_review_status)}</span>
|
||||||
)}
|
)}
|
||||||
{insight.target_reason_code && (
|
{insight.target_reason_code && (
|
||||||
<span className="rounded-full bg-white/70 px-2.5 py-1">原因 {insight.target_reason_code}</span>
|
<span className="rounded-full bg-white/70 px-2.5 py-1">原因 {toZhFeedbackReasonCode(insight.target_reason_code)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -437,7 +441,7 @@ export function FeedbackTrendPanel({
|
||||||
<div className="mt-5 border-t border-slate-100 pt-5">
|
<div className="mt-5 border-t border-slate-100 pt-5">
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Drilldown</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">钻取入口</p>
|
||||||
<h4 className="mt-1 text-lg font-semibold text-slate-900">一键钻取到样本列表</h4>
|
<h4 className="mt-1 text-lg font-semibold text-slate-900">一键钻取到样本列表</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
|
|
@ -465,7 +469,7 @@ export function FeedbackTrendPanel({
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Daily Volume</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">日度样本量</p>
|
||||||
<h3 className="mt-1 text-lg font-semibold text-slate-900">最近 {trend.window_days} 天样本量与状态分布</h3>
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">最近 {trend.window_days} 天样本量与状态分布</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { AlertTriangle, CheckCircle2, Loader2, Users, Zap } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Loader2, Users, Zap } from "lucide-react";
|
||||||
import { toZhSeverity } from "@/lib/display";
|
import { toZhConflictType, toZhSeverity } from "@/lib/display";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
@ -74,7 +74,7 @@ export default function ConflictsTab({ featureLoading, conflicts, onDetectConfli
|
||||||
<AlertTriangle className={`w-5 h-5 mt-0.5 ${String(conflict.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
<AlertTriangle className={`w-5 h-5 mt-0.5 ${String(conflict.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Badge variant="outline">{conflict.type}</Badge>
|
<Badge variant="outline">{conflict.display_type || toZhConflictType(conflict.type)}</Badge>
|
||||||
<Badge variant={String(conflict.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
<Badge variant={String(conflict.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||||
{toZhSeverity(conflict.severity)}
|
{toZhSeverity(conflict.severity)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { AlertCircle, AlertTriangle, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
import { AlertCircle, AlertTriangle, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||||
import { formatProjectNameWithId, toZhPriority, toZhSeverity } from "@/lib/display";
|
import {
|
||||||
|
formatProjectNameWithId,
|
||||||
|
formatTechnicalIdentifierLabel,
|
||||||
|
toZhPriority,
|
||||||
|
toZhSeverity,
|
||||||
|
toZhSourceKind,
|
||||||
|
} from "@/lib/display";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -250,19 +256,26 @@ export default function CreateProjectTab({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
|
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||||
{source.title || source.source_name}
|
{source.source_name}
|
||||||
</h5>
|
</h5>
|
||||||
<Badge variant="secondary">{source.source_kind}</Badge>
|
<Badge variant="secondary">
|
||||||
|
{source.display_source_kind || toZhSourceKind(source.source_kind)}
|
||||||
|
</Badge>
|
||||||
{source.origin && <Badge variant="outline">{source.origin}</Badge>}
|
{source.origin && <Badge variant="outline">{source.origin}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
{source.title && source.title !== source.source_name && (
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{source.title}</p>
|
||||||
|
)}
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
{source.description || "暂无描述"}
|
{source.description || "暂无描述"}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
{source.requested_via && <span>入口:{source.requested_via}</span>}
|
{source.requested_via && <span>入口:{source.requested_via}</span>}
|
||||||
{source.registry_id != null && <span>#{source.registry_id}</span>}
|
{source.registry_id != null && <span>#{source.registry_id}</span>}
|
||||||
{source.source_identifier && (
|
{(source.technical_identifier || source.source_identifier) && (
|
||||||
<span className="font-mono">标识:{source.source_identifier}</span>
|
<span className="font-mono">
|
||||||
|
{formatTechnicalIdentifierLabel(source.technical_identifier || source.source_identifier)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ReasonPicker } from "@/pages/ai-steward/components/feedback-center/Reas
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useToast } from "@/components/ui/toast";
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
import { toZhFeedbackReasonCode, toZhFeedbackSourceType } from "@/lib/display";
|
||||||
|
|
||||||
interface FeedbackCenterTabProps {
|
interface FeedbackCenterTabProps {
|
||||||
section: FeedbackCenterSection;
|
section: FeedbackCenterSection;
|
||||||
|
|
@ -259,7 +260,7 @@ export default function FeedbackCenterTab({
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{Object.entries(summary?.by_source_type || {}).map(([key, value]) => (
|
{Object.entries(summary?.by_source_type || {}).map(([key, value]) => (
|
||||||
<div key={key} className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3">
|
<div key={key} className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3">
|
||||||
<span className="text-sm text-slate-600">{key}</span>
|
<span className="text-sm text-slate-600">{toZhFeedbackSourceType(key)}</span>
|
||||||
<span className="text-sm font-semibold text-slate-900">{value}</span>
|
<span className="text-sm font-semibold text-slate-900">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -277,7 +278,7 @@ export default function FeedbackCenterTab({
|
||||||
<span className="text-sm font-medium text-slate-800">{item.label}</span>
|
<span className="text-sm font-medium text-slate-800">{item.label}</span>
|
||||||
<span className="text-xs text-slate-500">{item.count} 次</span>
|
<span className="text-xs text-slate-500">{item.count} 次</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500">{item.code}</p>
|
<p className="mt-1 text-xs text-slate-500">{toZhFeedbackReasonCode(item.code)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!summary?.top_reasons?.length && <p className="text-sm text-slate-500">原因榜单会随着样本增加自动出现。</p>}
|
{!summary?.top_reasons?.length && <p className="text-sm text-slate-500">原因榜单会随着样本增加自动出现。</p>}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type KnowledgeGraphCapabilitySummary,
|
type KnowledgeGraphCapabilitySummary,
|
||||||
} from "@/api/knowledgeGraph";
|
} from "@/api/knowledgeGraph";
|
||||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||||
|
import { toZhRoutingSource, toZhTierAlias } from "@/lib/display";
|
||||||
import { isManagerRole } from "@/lib/rbac";
|
import { isManagerRole } from "@/lib/rbac";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
|
@ -223,15 +224,15 @@ export default function KnowledgeGraphContextBoardPage() {
|
||||||
{matrixRows.map((row) => (
|
{matrixRows.map((row) => (
|
||||||
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||||
<TableCell className="font-medium">{row.function_name}</TableCell>
|
<TableCell className="font-medium">{row.function_name}</TableCell>
|
||||||
<TableCell>{row.tier}</TableCell>
|
<TableCell>{toZhTierAlias(row.tier)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{row.routing_snapshot.tier_alias || "-"}</div>
|
<div className="font-medium">{toZhTierAlias(row.routing_snapshot.tier_alias || "-")}</div>
|
||||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
{row.routing_snapshot.rule_name || "来自配置真源"}
|
{row.routing_snapshot.rule_name || "来自配置真源"}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
|
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
|
||||||
<TableCell>{row.routing_snapshot.source || "-"}</TableCell>
|
<TableCell>{toZhRoutingSource(row.routing_snapshot.source || "-")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{matrixRows.length === 0 ? (
|
{matrixRows.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||||
|
import { toZhRoutingSource, toZhTierAlias } from "@/lib/display";
|
||||||
|
|
||||||
const TIER_OPTIONS = ["low", "high"] as const;
|
const TIER_OPTIONS = ["low", "high"] as const;
|
||||||
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||||
|
|
@ -20,11 +21,11 @@ const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||||
rag_intent_classification: "意图识别阶段:先判断这是哪类问题,再决定后续处理路径。",
|
rag_intent_classification: "意图识别阶段:先判断这是哪类问题,再决定后续处理路径。",
|
||||||
signal_planner_analyze: "任务规划阶段:把需求拆成可执行步骤、产物和责任边界。",
|
signal_planner_analyze: "任务规划阶段:把需求拆成可执行步骤、产物和责任边界。",
|
||||||
classify_intent_ai_pmo_kernel: "轻量意图分流:快速判断是否进入 PMO 内核处理链路。",
|
classify_intent_ai_pmo_kernel: "轻量意图分流:快速判断是否进入 PMO 内核处理链路。",
|
||||||
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到 high 模型。",
|
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到高层模型。",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFunctionUsageHint(functionName: string): string {
|
function getFunctionUsageHint(functionName: string): string {
|
||||||
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的 low/high 选择。";
|
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的高低层选择。";
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeLastExecution(item: ModelRoutingItem): string {
|
function describeLastExecution(item: ModelRoutingItem): string {
|
||||||
|
|
@ -83,7 +84,7 @@ export default function ModelRoutingConfigPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ConfigSubpageHeader
|
<ConfigSubpageHeader
|
||||||
title="模型路由配置"
|
title="模型路由配置"
|
||||||
description="按 function_name 选择逻辑 tier 槽位,只暴露 low/high,真实模型由配置真源解析。"
|
description="按函数选择逻辑层级,只暴露中文说明,真实模型仍由配置真源解析。"
|
||||||
icon={<Settings2 className="h-5 w-5" />}
|
icon={<Settings2 className="h-5 w-5" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||||
|
|
@ -91,12 +92,12 @@ export default function ModelRoutingConfigPage() {
|
||||||
<FeatureIntroGuide
|
<FeatureIntroGuide
|
||||||
featureId="model-routing-config"
|
featureId="model-routing-config"
|
||||||
title="模型路由配置"
|
title="模型路由配置"
|
||||||
purpose="给不同函数选择 low/high 路由槽位,统一控制成本、速度和效果。"
|
purpose="给不同函数选择高低两层路由槽位,统一控制成本、速度和效果。"
|
||||||
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的 tier。"
|
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的逻辑层。"
|
||||||
steps={[
|
steps={[
|
||||||
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
|
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
|
||||||
"只调整 low / high tier_alias,点“保存路由”。",
|
"只调整高层或低层,点“保存路由”。",
|
||||||
"触发一次该功能调用,观察最近一次执行是否出现 low -> high 升级。",
|
"触发一次该功能调用,观察最近一次执行是否出现低层升级到高层。",
|
||||||
]}
|
]}
|
||||||
outputs={[
|
outputs={[
|
||||||
"函数级 tier override",
|
"函数级 tier override",
|
||||||
|
|
@ -124,7 +125,7 @@ export default function ModelRoutingConfigPage() {
|
||||||
>
|
>
|
||||||
{TIER_OPTIONS.map((item) => (
|
{TIER_OPTIONS.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{toZhTierAlias(item)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -174,11 +175,11 @@ export default function ModelRoutingConfigPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Function</TableHead>
|
<TableHead>函数</TableHead>
|
||||||
<TableHead>Tier</TableHead>
|
<TableHead>逻辑层</TableHead>
|
||||||
<TableHead>Source</TableHead>
|
<TableHead>配置来源</TableHead>
|
||||||
<TableHead>Rule</TableHead>
|
<TableHead>规则</TableHead>
|
||||||
<TableHead>Last</TableHead>
|
<TableHead>最近执行</TableHead>
|
||||||
<TableHead>升级</TableHead>
|
<TableHead>升级</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -199,8 +200,8 @@ export default function ModelRoutingConfigPage() {
|
||||||
{getFunctionUsageHint(item.function_name)}
|
{getFunctionUsageHint(item.function_name)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.tier_alias}</TableCell>
|
<TableCell>{toZhTierAlias(item.tier_alias)}</TableCell>
|
||||||
<TableCell>{item.source}</TableCell>
|
<TableCell>{toZhRoutingSource(item.source)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{item.rule_name || "来自配置真源"}</div>
|
<div className="font-medium">{item.rule_name || "来自配置真源"}</div>
|
||||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Loading…
Reference in New Issue