Strengthen AI system charter and governance constraints

This commit is contained in:
Yuuuu 2026-03-31 14:23:55 +08:00
parent a01c451195
commit 6bb8354b8d
25 changed files with 2594 additions and 141 deletions

View File

@ -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` 的结构
@ -190,4 +267,4 @@ frontend/ # React 前端
### ✅ 文件操作前必须: ### ✅ 文件操作前必须:
1. 先列出将要操作的文件路径 1. 先列出将要操作的文件路径
2. 明确告知用户操作后果 2. 明确告知用户操作后果
3. 等待用户输入"确认"后才能执行 3. 等待用户输入"确认"后才能执行

View File

@ -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`

View File

@ -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)}")

View File

@ -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)

View File

@ -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(

View File

@ -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"},

View File

@ -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"] == "技能错配"

View File

@ -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

View File

@ -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"] == "进度延期预警"

View File

@ -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[];

View File

@ -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

View File

@ -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>
)} )}

View File

@ -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>`)

View File

@ -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>

View File

@ -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>}

View File

@ -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"}`}>

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>}

View File

@ -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 ? (

View File

@ -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">

1710
yesterday_tasks_20260331.csv Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.