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`
- 新增接口必须遵循响应包络:`{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 抽取与结构化结果解析
### 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. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
@ -190,4 +267,4 @@ frontend/ # React 前端
### ✅ 文件操作前必须:
1. 先列出将要操作的文件路径
2. 明确告知用户操作后果
3. 等待用户输入"确认"后才能执行
3. 等待用户输入"确认"后才能执行

View File

@ -4,6 +4,7 @@
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
## 2. 项目定位
- 项目名:`PMFlow / Neural Sync`

View File

@ -4,6 +4,27 @@ from .shared import *
from .helpers import _parse_related_users, _utc_now_naive
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])
async def get_cognitive_conflicts(
current_user: User = Depends(get_current_user),
@ -41,6 +62,7 @@ async def get_cognitive_conflicts(
CognitiveConflictResponse(
id=str(c.id),
type=c.conflict_type,
display_type=_display_conflict_type(c.conflict_type),
description=c.description,
severity="high" if "critical" in (c.description or "").lower() else "medium",
related_users=_parse_related_users(c.involved_user_ids),
@ -250,4 +272,3 @@ async def check_task_charter_mismatch(
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}")

View File

@ -11,6 +11,27 @@ class ProjectFromTextRequest(BaseModel):
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):
"""零配置项目创建响应"""
project: Dict[str, Any]
@ -18,13 +39,14 @@ class ProjectFromTextResponse(BaseModel):
charter: Dict[str, Any]
tasks: 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]
class CognitiveConflictResponse(BaseModel):
"""认知冲突响应"""
id: str
type: str
display_type: str
description: str
severity: str
resolution_suggestion: Optional[str] = None
@ -114,6 +136,10 @@ class ExecutiveBriefingMetric(BaseModel):
class ExecutiveBriefingItem(BaseModel):
id: str
title: str
display_title: str
display_subtitle: str
display_source: str
technical_identifier: Optional[str] = None
detail: str
detail_lines: List[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 *
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:
async def _normalize_project_creation_draft(
self,
@ -183,6 +237,7 @@ class AIStewardCreationPersistMixin:
requirement_items=normalized["analysis"].get("requirement_items") if isinstance(normalized["analysis"].get("requirement_items"), list) else [],
default_source_kind="custom",
)
signal_sources = _enrich_signal_sources(signal_sources)
charter_data = normalized["charter"]
charter = TruthNode(

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import Any, Optional
from zoneinfo import ZoneInfo
@ -74,7 +75,36 @@ SOURCE_KIND_LABELS = {
"scheduled": "定时来源",
"manual": "人工录入",
"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]:
@ -98,6 +128,19 @@ def _safe_text(value: Any, *, fallback: str = "-") -> str:
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]:
if value is None:
return None
@ -122,7 +165,24 @@ def _label_source_kind(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
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(
@ -252,6 +312,12 @@ class ExecutiveBriefingService:
return False
if not str(item.get("id") or "").strip():
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):
return False
if not isinstance(item.get("meta"), list):
@ -479,7 +545,10 @@ class ExecutiveBriefingService:
for item in items[:3]:
if not isinstance(item, dict):
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()
async def _get_config_row(self) -> Optional[Integration]:
@ -600,11 +669,16 @@ class ExecutiveBriefingService:
@staticmethod
def _pick_display_text(*values: Any) -> Optional[str]:
fallback_text: Optional[str] = None
for value in values:
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 None
return fallback_text
@classmethod
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})
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]:
if project_id is None:
return None
@ -710,12 +796,32 @@ class ExecutiveBriefingService:
related_user_ids: Optional[list[int]] = None,
occurred_at: Optional[datetime] = 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]:
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()]
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 {
"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_lines": cleaned_detail_lines,
"meta": cleaned_meta,
@ -821,6 +927,7 @@ class ExecutiveBriefingService:
if not self._is_reportable_signal_source(title=title_text, source_identifier=row.source_identifier):
continue
kind_label = _label_source_kind(row.source_kind)
status_label = _label_status(row.onboarding_status)
items.append(
await self._build_brief_item(
item_id=f"signal_source:{row.id}",
@ -829,19 +936,25 @@ class ExecutiveBriefingService:
kind="signal_source",
detail_lines=self._detail_lines(
kind_label,
_safe_text(row.source_identifier),
f"状态 {_label_status(row.onboarding_status)}",
f"接入状态 {status_label}",
_safe_text(row.description, fallback=""),
),
meta=self._meta_entries(
("来源类型", kind_label),
("标识", _safe_text(row.source_identifier)),
("接入状态", _label_status(row.onboarding_status)),
("接入状态", status_label),
("负责人", await self._get_user_name(user_id)),
("项目", await self._get_project_name(project_id)),
),
project_id=project_id,
user_id=user_id,
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(
project_id,
params={
@ -1132,6 +1245,10 @@ class ExecutiveBriefingService:
project_id=normalized_project_id,
related_user_ids=related_user_ids,
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(
normalized_project_id,
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 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.dependencies.auth import get_current_user
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_name": "核心痛点 - 内容本地化",
"source_identifier": "custom:core-pain-content-localization",
"display_source_kind": "自定义来源",
"technical_identifier": "custom:core-pain-content-localization",
"description": "翻译公司贵、慢、不地道",
"section": "核心痛点",
}
@ -76,6 +79,16 @@ async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -
assert payload["message"] == "success"
assert payload["data"]["project"]["id"] == "11"
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

View File

@ -119,7 +119,21 @@ async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, mo
"title": "今日需拍板",
"summary": "有 2 项需要拍板",
"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",
}
],
@ -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["data"]["mode"] == "live"
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
@ -268,6 +286,84 @@ async def test_executive_briefing_service_formats_management_summary_text(db_ses
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
async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None:
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["detail_lines"][0] == "159% 负载"
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_name: string;
source_identifier: string;
display_source_kind?: string | null;
technical_identifier?: string | null;
description?: string | null;
requested_via?: string;
status?: string;
@ -89,6 +91,7 @@ export interface SignalSourceRepairResponse {
export interface CognitiveConflict {
id: string;
type: string;
display_type: string;
description: string;
severity: string;
related_users: string[];
@ -135,6 +138,10 @@ export interface BriefingMetaEntry {
export interface ExecutiveBriefingItem {
id: string;
title: string;
display_title: string;
display_subtitle: string;
display_source: string;
technical_identifier?: string | null;
detail: string;
detail_lines: string[];
meta: BriefingMetaEntry[];

View File

@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { TabsContent } from "@/components/ui/tabs";
import { formatBeijingDateTime } from "@/lib/datetime";
import { toZhKnowledgeDocStatus, toZhKnowledgeFileType, toZhKnowledgeSourceType } from "@/lib/display";
import type { KnowledgeDoc } from "../types";
interface KnowledgeTabProps {
@ -96,7 +97,7 @@ export default function KnowledgeTab({
添加中…
</>
) : (
"Add URL"
"添加 URL"
)}
</Button>
</div>
@ -118,7 +119,9 @@ export default function KnowledgeTab({
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<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">
上传时间: {formatBeijingDateTime(doc.created_at)}
{doc.last_fetched_at ? ` · 最近抓取: ${formatBeijingDateTime(doc.last_fetched_at)}` : ""}
@ -128,7 +131,7 @@ export default function KnowledgeTab({
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={doc.status === "complete" ? "default" : doc.status === "failed" ? "destructive" : "secondary"}>
{doc.status === "processing" ? "processing" : doc.status === "complete" ? "complete" : "failed"}
{toZhKnowledgeDocStatus(doc.status)}
</Badge>
{doc.source_type === "url" && (
<Button

View File

@ -9,8 +9,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatTechnicalIdentifierLabel } from "@/lib/display";
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 {
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 {
open: boolean;
section: ExecutiveBriefingSection | null;
@ -92,7 +79,7 @@ function getItemDetailLines(item: ExecutiveBriefingItem): string[] {
if (Array.isArray(item.detail_lines) && item.detail_lines.length) {
return item.detail_lines;
}
const detail = String(item.detail || "").trim();
const detail = getBriefingItemDisplaySubtitle(item);
return detail ? [detail] : [];
}
@ -110,6 +97,9 @@ function getItemMeta(item: ExecutiveBriefingItem): Array<{ label: string; value:
if (item.occurred_at) {
fallback.push({ label: "时间", value: item.occurred_at });
}
if (item.display_source) {
fallback.push({ label: "来源", value: item.display_source });
}
return fallback;
}
@ -305,13 +295,17 @@ export default function BriefingDetailDrawer({
>
<div className="min-w-0">
<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">
{itemKindLabel(item.kind)}
{getBriefingItemDisplaySource(item)}
</span>
<Badge className={toneBadgeClass(item.tone)}>{toneLabel(item.tone)}</Badge>
</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>
{expandedItem ? (
<ChevronDown className="mt-1 h-4 w-4 shrink-0 text-slate-500" />
@ -343,6 +337,11 @@ export default function BriefingDetailDrawer({
</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)}
</div>
)}

View File

@ -3,7 +3,13 @@ import type {
ExecutiveBriefingHero,
ExecutiveBriefingSection,
} from "@/pages/ai-steward/types";
import { groupBriefingItemsByProject } from "./BriefingSectionCard";
import { formatTechnicalIdentifierLabel } from "@/lib/display";
import {
getBriefingItemDisplaySource,
getBriefingItemDisplaySubtitle,
getBriefingItemDisplayTitle,
groupBriefingItemsByProject,
} from "./BriefingSectionCard";
export interface PrintableBriefingPayload {
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 {
return mode === "live" ? "实时版" : "晨报快照";
}
@ -118,6 +105,9 @@ function renderItemMeta(item: ExecutiveBriefingSection["items"][number]): Array<
return item.meta;
}
const fallback: Array<{ label: string; value: string }> = [];
if (item.display_source) {
fallback.push({ label: "来源", value: item.display_source });
}
if (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) {
return item.detail_lines;
}
const detail = String(item.detail || "").trim();
const detail = getBriefingItemDisplaySubtitle(item);
return detail ? [detail] : [];
}
@ -163,14 +153,14 @@ function renderItems(section: ExecutiveBriefingSection): string {
const detailLines = renderItemDetailLines(item);
return `
<div class="item-card">
<div class="item-head">
<div class="item-head">
<div>
<span class="item-title">${escapeHtml(item.title)}</span>
<span class="item-kind">${escapeHtml(itemKindLabel(item.kind))}</span>
<span class="item-title">${escapeHtml(getBriefingItemDisplayTitle(item))}</span>
<span class="item-kind">${escapeHtml(getBriefingItemDisplaySource(item))}</span>
</div>
<span class="tone tone-${escapeHtml(item.tone)}">${toneLabel(item.tone)}</span>
</div>
<div class="item-detail">${escapeHtml(item.detail)}</div>
<div class="item-detail">${escapeHtml(getBriefingItemDisplaySubtitle(item))}</div>
<div class="meta-grid">
${meta
.map(
@ -183,6 +173,7 @@ function renderItems(section: ExecutiveBriefingSection): string {
)
.join("")}
</div>
${item.technical_identifier ? `<div class="action-tag">${escapeHtml(formatTechnicalIdentifierLabel(item.technical_identifier))}</div>` : ""}
<div class="detail-lines">
${detailLines
.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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
shouldHideTechnicalIdentifierFromPrimaryText,
toZhConflictType,
toZhSourceKind,
} from "@/lib/display";
import { cn } from "@/lib/utils";
import type { ExecutiveBriefingItem, ExecutiveBriefingSection } from "@/pages/ai-steward/types";
@ -71,6 +76,91 @@ function getMetaValue(item: ExecutiveBriefingItem, label: string): string | 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 {
switch (kind) {
case "signal":
@ -96,14 +186,15 @@ export function groupBriefingItemsByProject(items: ExecutiveBriefingItem[]): Bri
const projectId = typeof item.project_id === "number" ? item.project_id : null;
const projectName = String(item.project_name || "").trim();
const ownerName = String(item.owner_name || "").trim();
const sourceIdentifier = 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 title = "";
let categoryLabel = "";
let previewText = String(item.detail || "").trim() || "展开后查看详细内容";
let previewText = fallbackSubtitle;
if (projectId !== null && projectName) {
key = `project:${projectId}`;
@ -111,18 +202,17 @@ export function groupBriefingItemsByProject(items: ExecutiveBriefingItem[]): Bri
categoryLabel = "项目";
} else if (item.kind === "signal_source") {
key = `signal-source:${item.id}`;
title = sourceIdentifier ? `${fallbackTitle}` : fallbackTitle;
categoryLabel = "信号源";
previewText = sourceIdentifier ? `来源标识 ${sourceIdentifier}` : previewText;
title = fallbackTitle;
categoryLabel = sourceLabel;
} else if (item.kind === "capacity" || item.user_id || ownerName) {
key = `user:${item.user_id ?? item.id}`;
title = ownerName || fallbackTitle;
categoryLabel = "成员";
previewText = workloadValue ? `${workloadValue}|${String(item.detail || "").trim()}` : previewText;
previewText = workloadValue ? `${workloadValue}|${fallbackSubtitle}` : previewText;
} else {
key = `item:${item.id}`;
title = fallbackTitle;
categoryLabel = itemKindLabel(item.kind);
categoryLabel = sourceLabel;
}
const existing = groups.get(key);
@ -261,7 +351,9 @@ export default function BriefingSectionCard({
<span className="text-xs text-slate-500">{group.items.length} 条</span>
</div>
<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>
</div>
</button>
@ -283,13 +375,15 @@ export default function BriefingSectionCard({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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">
{itemKindLabel(item.kind)}
{getBriefingItemDisplaySource(item)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{item.detail}
{getBriefingItemDisplaySubtitle(item)}
</p>
</div>
<span className="shrink-0 text-xs text-slate-400">展开查看</span>

View File

@ -1,5 +1,12 @@
import type { FeedbackCenterRecord, FeedbackCenterRecordRevision } from "@/api/feedbackCenter";
import { Badge } from "@/components/ui/badge";
import {
formatTechnicalIdentifierLabel,
toZhFeedbackFieldName,
toZhFeedbackReasonCode,
toZhFeedbackSourceType,
toZhReviewStatus,
} from "@/lib/display";
interface FeedbackDetailProps {
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="flex flex-wrap items-start justify-between gap-3">
<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">
{record.object_title || record.source_title || `记录 #${record.id}`}
</h3>
<p className="mt-1 text-sm text-slate-500">
{record.source_type} · 字段 {record.field_name}
{toZhFeedbackSourceType(record.source_type)} · 字段 {toZhFeedbackFieldName(record.field_name)}
</p>
</div>
<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 && (
<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="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>
<p className="text-xs text-slate-500">项目</p>
@ -72,7 +79,7 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
</div>
<div>
<p className="text-xs text-slate-500">原因码</p>
<p>{record.reason_code || "—"}</p>
<p>{toZhFeedbackReasonCode(record.reason_code)}</p>
</div>
<div>
<p className="text-xs text-slate-500">置信度</p>
@ -81,21 +88,30 @@ export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
</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">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">
{formatJson(record.ai_snapshot)}
</pre>
</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">
{diffCard("AI Before", record.before_value, "before")}
{diffCard("Human After", record.after_value, "after")}
{diffCard("AI 原始结果", record.before_value, "before")}
{diffCard("人工修正结果", record.after_value, "after")}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<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>
</div>
{!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 { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { toZhFeedbackFieldName, toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
interface FeedbackListProps {
items: FeedbackCenterRecord[];
@ -30,7 +31,7 @@ function sourceLabel(sourceType: string): string {
if (sourceType === "task_breakdown" || sourceType === "task_override") return "任务";
if (sourceType === "qa_feedback") return "QA";
if (sourceType === "signal_feedback") return "信号";
return sourceType || "未知";
return toZhFeedbackSourceType(sourceType) || "未知";
}
export function FeedbackList({
@ -54,7 +55,7 @@ export function FeedbackList({
<div className="border-b border-slate-100 p-4">
<div className="space-y-3">
<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>
</div>
<Input
@ -125,11 +126,11 @@ export function FeedbackList({
{item.object_title || item.source_title || `${sourceLabel(item.source_type)} #${item.object_id}`}
</p>
<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>
</div>
<Badge className={selected ? "border-white/20 bg-white/10 text-white" : statusTone(item.review_status)}>
{item.review_status}
{toZhReviewStatus(item.review_status)}
</Badge>
</div>
<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 { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
interface MetricsPanelProps {
summary?: FeedbackCenterSummary | null;
@ -19,20 +20,6 @@ const EMPTY_METRICS = {
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 {
return `${Math.round(value * 100)}%`;
}
@ -142,7 +129,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
const sourceRows = Object.entries(bySourceType)
.map(([label, count]) => ({
label: SOURCE_LABELS[label] || label,
label: toZhFeedbackSourceType(label),
count,
percent: sourceTotal > 0 ? count / sourceTotal : 0,
}))
@ -150,7 +137,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
const statusRows = Object.entries(byStatus)
.map(([label, count]) => ({
label: STATUS_LABELS[label] || label,
label: toZhReviewStatus(label),
count,
percent: statusTotal > 0 ? count / statusTotal : 0,
}))
@ -192,7 +179,7 @@ export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
<div className="grid gap-4 xl:grid-cols-2">
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
<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>
<p className="mt-1 text-sm text-slate-500">把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。</p>
<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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toZhFeedbackReasonCode, toZhFeedbackSourceType, toZhReviewStatus } from "@/lib/display";
interface TrendPanelProps {
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="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<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>
<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="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<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>
<p className="mt-2 text-sm text-slate-500">
这里先告诉你近 {windowDays} 天什么值得处理,再给你一键跳转到对应样本,最后再看趋势图做复盘。
@ -409,10 +410,13 @@ export function FeedbackTrendPanel({
跳转:{insight.target_section === "task" ? "任务" : insight.target_section === "qa" ? "QA" : insight.target_section === "signal" ? "信号" : "总览"}
</span>
{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 && (
<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>
<Button
@ -437,7 +441,7 @@ export function FeedbackTrendPanel({
<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>
<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>
</div>
<p className="text-sm text-slate-500">
@ -465,7 +469,7 @@ export function FeedbackTrendPanel({
<CardContent className="p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<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>
</div>
<div className="flex flex-wrap gap-2">

View File

@ -1,5 +1,5 @@
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 { Button } from "@/components/ui/button";
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"}`} />
<div className="flex-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"}>
{toZhSeverity(conflict.severity)}
</Badge>

View File

@ -1,5 +1,11 @@
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 { Button } from "@/components/ui/button";
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="flex flex-wrap items-center gap-2">
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
{source.title || source.source_name}
{source.source_name}
</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>}
</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">
{source.description || "暂无描述"}
</p>
<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.registry_id != null && <span>#{source.registry_id}</span>}
{source.source_identifier && (
<span className="font-mono">标识:{source.source_identifier}</span>
{(source.technical_identifier || source.source_identifier) && (
<span className="font-mono">
{formatTechnicalIdentifierLabel(source.technical_identifier || source.source_identifier)}
</span>
)}
</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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/toast";
import { toZhFeedbackReasonCode, toZhFeedbackSourceType } from "@/lib/display";
interface FeedbackCenterTabProps {
section: FeedbackCenterSection;
@ -259,7 +260,7 @@ export default function FeedbackCenterTab({
<div className="mt-4 space-y-3">
{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">
<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>
</div>
))}
@ -277,7 +278,7 @@ export default function FeedbackCenterTab({
<span className="text-sm font-medium text-slate-800">{item.label}</span>
<span className="text-xs text-slate-500">{item.count} 次</span>
</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>
))}
{!summary?.top_reasons?.length && <p className="text-sm text-slate-500">原因榜单会随着样本增加自动出现。</p>}

View File

@ -13,6 +13,7 @@ import {
type KnowledgeGraphCapabilitySummary,
} from "@/api/knowledgeGraph";
import { formatBeijingDateTime } from "@/lib/datetime";
import { toZhRoutingSource, toZhTierAlias } from "@/lib/display";
import { isManagerRole } from "@/lib/rbac";
import { useAuthStore } from "@/stores/auth";
@ -223,15 +224,15 @@ export default function KnowledgeGraphContextBoardPage() {
{matrixRows.map((row) => (
<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>{row.tier}</TableCell>
<TableCell>{toZhTierAlias(row.tier)}</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">
{row.routing_snapshot.rule_name || "来自配置真源"}
</div>
</TableCell>
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
<TableCell>{row.routing_snapshot.source || "-"}</TableCell>
<TableCell>{toZhRoutingSource(row.routing_snapshot.source || "-")}</TableCell>
</TableRow>
))}
{matrixRows.length === 0 ? (

View File

@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { formatBeijingDateTime } from "@/lib/datetime";
import { toZhRoutingSource, toZhTierAlias } from "@/lib/display";
const TIER_OPTIONS = ["low", "high"] as const;
const FUNCTION_USAGE_HINTS: Record<string, string> = {
@ -20,11 +21,11 @@ const FUNCTION_USAGE_HINTS: Record<string, string> = {
rag_intent_classification: "意图识别阶段:先判断这是哪类问题,再决定后续处理路径。",
signal_planner_analyze: "任务规划阶段:把需求拆成可执行步骤、产物和责任边界。",
classify_intent_ai_pmo_kernel: "轻量意图分流:快速判断是否进入 PMO 内核处理链路。",
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到 high 模型。",
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到高层模型。",
};
function getFunctionUsageHint(functionName: string): string {
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的 low/high 选择。";
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的高低层选择。";
}
function describeLastExecution(item: ModelRoutingItem): string {
@ -83,7 +84,7 @@ export default function ModelRoutingConfigPage() {
<div className="space-y-6">
<ConfigSubpageHeader
title="模型路由配置"
description="按 function_name 选择逻辑 tier 槽位,只暴露 low/high,真实模型由配置真源解析。"
description="按函数选择逻辑层级,只暴露中文说明,真实模型仍由配置真源解析。"
icon={<Settings2 className="h-5 w-5" />}
tone="blue"
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
@ -91,12 +92,12 @@ export default function ModelRoutingConfigPage() {
<FeatureIntroGuide
featureId="model-routing-config"
title="模型路由配置"
purpose="给不同函数选择 low/high 路由槽位,统一控制成本、速度和效果。"
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的 tier。"
purpose="给不同函数选择高低两层路由槽位,统一控制成本、速度和效果。"
whenToUse="某个功能效果差、成本高、超时多时,用它切换函数当前使用的逻辑层。"
steps={[
"在右侧选中目标函数,左侧会自动回填当前逻辑 tier。",
"只调整 low / high tier_alias,点“保存路由”。",
"触发一次该功能调用,观察最近一次执行是否出现 low -> high 升级。",
"只调整高层或低层,点“保存路由”。",
"触发一次该功能调用,观察最近一次执行是否出现低层升级到高层。",
]}
outputs={[
"函数级 tier override",
@ -124,7 +125,7 @@ export default function ModelRoutingConfigPage() {
>
{TIER_OPTIONS.map((item) => (
<option key={item} value={item}>
{item}
{toZhTierAlias(item)}
</option>
))}
</select>
@ -174,11 +175,11 @@ export default function ModelRoutingConfigPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Function</TableHead>
<TableHead>Tier</TableHead>
<TableHead>Source</TableHead>
<TableHead>Rule</TableHead>
<TableHead>Last</TableHead>
<TableHead>函数</TableHead>
<TableHead>逻辑层</TableHead>
<TableHead>配置来源</TableHead>
<TableHead>规则</TableHead>
<TableHead>最近执行</TableHead>
<TableHead>升级</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
@ -199,8 +200,8 @@ export default function ModelRoutingConfigPage() {
{getFunctionUsageHint(item.function_name)}
</div>
</TableCell>
<TableCell>{item.tier_alias}</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>{toZhTierAlias(item.tier_alias)}</TableCell>
<TableCell>{toZhRoutingSource(item.source)}</TableCell>
<TableCell>
<div className="font-medium">{item.rule_name || "来自配置真源"}</div>
<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.