From 77a094e363ee1a9e616e1dfb8579a545a48b05ea Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Thu, 26 Mar 2026 11:49:34 +0800 Subject: [PATCH] Add scoped trend controls to feedback center --- CLAUDE.md | 15 +- .../20260326_create_feedback_center_tables.py | 542 +++++++++++ backend/app/api/ai_steward_api/__init__.py | 2 + .../ai_steward_api/routes_feedback_center.py | 157 ++++ backend/app/api/feedbacks.py | 60 +- backend/app/api/rag_api/routes_qa.py | 37 +- backend/app/api/signals_api/feedback.py | 62 +- .../app/api/sstc_api/tasks_ai_breakdown.py | 18 + .../app/api/sstc_api/tasks_update_delete.py | 9 + backend/app/config/ai_prompt_registry.py | 131 ++- backend/app/config/ai_prompts.py | 75 +- .../app/config/ai_prompts_catalog/__init__.py | 4 +- .../app/config/ai_prompts_catalog/part_01.py | 316 +++---- .../app/config/ai_prompts_catalog/part_02.py | 224 +++-- .../app/config/ai_prompts_catalog/part_03.py | 373 +++++--- .../app/config/ai_prompts_catalog/part_04.py | 175 ++-- .../ai_prompts_catalog/part_scenes_01.py | 22 +- .../ai_prompts_catalog/part_scenes_03.py | 11 +- .../ai_prompts_catalog/part_scenes_04.py | 25 +- .../ai_prompts_catalog/part_scenes_05.py | 19 +- .../ai_prompts_catalog/part_scenes_06.py | 26 +- backend/app/models/__init__.py | 3 + backend/app/models/feedback_center.py | 79 ++ backend/app/schemas/__init__.py | 28 + backend/app/schemas/feedback_center.py | 209 +++++ .../app/services/feedback_center/__init__.py | 20 + .../app/services/feedback_center/mappers.py | 296 ++++++ .../feedback_center/reason_catalog.py | 364 ++++++++ .../app/services/feedback_center/service.py | 883 ++++++++++++++++++ backend/requirements.txt | 2 + .../tests/test_ai_prompt_registry_contract.py | 49 +- backend/tests/test_feedback_center_api.py | 353 +++++++ backend/tests/test_feedback_center_service.py | 787 ++++++++++++++++ .../test_prompt_profile_bundle_contract.py | 21 + frontend/src/api/feedbackCenter.ts | 278 ++++++ .../components/sstc/FeedbackSummaryCard.tsx | 125 +++ .../sstc/SSTCExecutionBoardPanel.tsx | 10 + .../components/sstc/SSTCTaskStagePanel.tsx | 4 + .../src/components/sstc/SSTCWorkspace.tsx | 1 + .../src/pages/ai-steward/AIStewardPage.tsx | 139 ++- .../feedback-center/FeedbackDetail.tsx | 125 +++ .../feedback-center/FeedbackList.tsx | 131 +++ .../feedback-center/MetricsPanel.tsx | 268 ++++++ .../feedback-center/ReasonPicker.tsx | 142 +++ .../components/feedback-center/TrendPanel.tsx | 382 ++++++++ .../ai-steward/sections/FeedbackCenterTab.tsx | 306 ++++++ frontend/src/pages/ai-steward/types.ts | 13 + frontend/tsconfig.tsbuildinfo | 2 +- 48 files changed, 6693 insertions(+), 630 deletions(-) create mode 100644 backend/alembic/versions/20260326_create_feedback_center_tables.py create mode 100644 backend/app/api/ai_steward_api/routes_feedback_center.py create mode 100644 backend/app/models/feedback_center.py create mode 100644 backend/app/schemas/feedback_center.py create mode 100644 backend/app/services/feedback_center/__init__.py create mode 100644 backend/app/services/feedback_center/mappers.py create mode 100644 backend/app/services/feedback_center/reason_catalog.py create mode 100644 backend/app/services/feedback_center/service.py create mode 100644 backend/tests/test_feedback_center_api.py create mode 100644 backend/tests/test_feedback_center_service.py create mode 100644 backend/tests/test_prompt_profile_bundle_contract.py create mode 100644 frontend/src/api/feedbackCenter.ts create mode 100644 frontend/src/components/sstc/FeedbackSummaryCard.tsx create mode 100644 frontend/src/pages/ai-steward/components/feedback-center/FeedbackDetail.tsx create mode 100644 frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx create mode 100644 frontend/src/pages/ai-steward/components/feedback-center/MetricsPanel.tsx create mode 100644 frontend/src/pages/ai-steward/components/feedback-center/ReasonPicker.tsx create mode 100644 frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx create mode 100644 frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx diff --git a/CLAUDE.md b/CLAUDE.md index db330c0a..a4905d35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,8 +135,18 @@ backend/ - 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) +- 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径) - 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) +- 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填) +- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域) +- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型) +- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型) +- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`) +- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选) +- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填) +- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域) +- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -346,7 +356,9 @@ frontend/ - 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`) - 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) - 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) -- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`(AI Steward 分阶段 UI 拆分) +- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分) +- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选) +- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装) - 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件) - 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`) - 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper) @@ -377,6 +389,7 @@ frontend/ - `SSTCReleaseCreateDialog.tsx`:发布创建弹窗 - `SSTCReviewCreateDialog.tsx`:复盘创建弹窗 - `AIVisibilityPanel.tsx`:AI 可视化信息面板 +- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用) - `SSTCGlobalHeader.tsx`:SSTC 全局头部 - `SSTCStageNav.tsx`:阶段导航 - `SSTCChainView.tsx`:链路视图 diff --git a/backend/alembic/versions/20260326_create_feedback_center_tables.py b/backend/alembic/versions/20260326_create_feedback_center_tables.py new file mode 100644 index 00000000..d5788438 --- /dev/null +++ b/backend/alembic/versions/20260326_create_feedback_center_tables.py @@ -0,0 +1,542 @@ +"""create feedback center tables + +Revision ID: 20260326_create_feedback_center_tables +Revises: 20260326_add_task_breakdown_payload_fields +Create Date: 2026-03-26 +""" + +from __future__ import annotations + +import json +from typing import Any + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260326_create_feedback_center_tables" +down_revision = "20260326_add_task_breakdown_payload_fields" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name)) + + +def _safe_int(value: Any) -> int | None: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_json_if_needed(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except (TypeError, ValueError): + return value + return value + + +def _build_reason_candidates(*, source_type: str, field_name: str, before_value: Any, after_value: Any) -> list[dict[str, str]]: + candidates: list[dict[str, str]] = [] + field = str(field_name or "").strip().lower() + source = str(source_type or "").strip().lower() + + def _add(code: str, label: str, description: str) -> None: + if any(str(item.get("code") or "") == code for item in candidates): + return + candidates.append({"code": code, "label": label, "description": description}) + + if before_value not in (None, "", [], {}) and after_value in (None, "", [], {}): + _add("incomplete_output", "输出信息不完整", "模型输出缺失关键信息字段") + if "estimated" in field or "hours" in field: + _add("effort_estimation_bias", "工时估算偏差", "任务工时估算与实际认知偏差较大") + if "priority" in field: + _add("priority_misalignment", "优先级错配", "优先级建议与业务紧急度不匹配") + if "assignee" in field or "skill" in field: + _add("capability_mismatch", "能力匹配偏差", "人员能力与任务推荐存在偏差") + if "strategic" in field or source == "signal_feedback": + _add("strategic_judgement_mismatch", "战略判断偏差", "战略属性判定与人工判断不一致") + if source == "qa_feedback": + _add("answer_incorrect", "答案不准确", "AI 输出与业务事实不一致") + if not candidates: + _add("manual_override", "人工覆盖", "由人工介入修改后形成的反馈记录") + return candidates + + +def _record_exists( + bind, + *, + tenant_id: int, + source_type: str, + source_id: int, + object_type: str, + object_id: int, + field_name: str, +) -> bool: + row = bind.execute( + sa.text( + """ + SELECT id + FROM feedback_center_records + WHERE tenant_id = :tenant_id + AND source_type = :source_type + AND source_id = :source_id + AND object_type = :object_type + AND object_id = :object_id + AND field_name = :field_name + LIMIT 1 + """ + ), + { + "tenant_id": int(tenant_id), + "source_type": str(source_type), + "source_id": int(source_id), + "object_type": str(object_type), + "object_id": int(object_id), + "field_name": str(field_name), + }, + ).first() + return row is not None + + +def _insert_record(bind, payload: dict[str, Any]) -> None: + bind.execute( + sa.text( + """ + INSERT INTO feedback_center_records ( + tenant_id, + project_id, + source_type, + source_id, + object_type, + object_id, + field_name, + before_value, + after_value, + ai_snapshot, + ai_confidence, + reason_candidates, + reason_code, + reason_text, + review_status, + reviewed_by, + reviewed_at, + source_title, + object_title, + auto_assigned + ) VALUES ( + :tenant_id, + :project_id, + :source_type, + :source_id, + :object_type, + :object_id, + :field_name, + :before_value, + :after_value, + :ai_snapshot, + :ai_confidence, + :reason_candidates, + :reason_code, + :reason_text, + :review_status, + :reviewed_by, + :reviewed_at, + :source_title, + :object_title, + :auto_assigned + ) + """ + ), + payload, + ) + + +def _backfill_from_qa_feedback(bind) -> None: + if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"): + return + rows = bind.execute( + sa.text( + """ + SELECT + f.id AS feedback_id, + f.tenant_id AS tenant_id, + f.qa_id AS qa_id, + f.rating AS rating, + f.helpfulness AS helpfulness, + f.error_types AS error_types, + f.correct_answer AS correct_answer, + f.submitted_by AS submitted_by, + f.created_at AS created_at, + r.project_id AS project_id, + r.question AS question, + r.answer AS answer + FROM qa_feedback f + JOIN qa_records r ON r.id = f.qa_id + """ + ) + ).mappings().all() + + for row in rows: + tenant_id = _safe_int(row.get("tenant_id")) + source_id = _safe_int(row.get("feedback_id")) + object_id = _safe_int(row.get("qa_id")) + if tenant_id is None or source_id is None or object_id is None: + continue + if _record_exists( + bind, + tenant_id=tenant_id, + source_type="qa_feedback", + source_id=source_id, + object_type="qa_record", + object_id=object_id, + field_name="qa_feedback", + ): + continue + before_value = {"answer": row.get("answer")} + after_value = { + "rating": row.get("rating"), + "helpfulness": row.get("helpfulness"), + "error_types": _parse_json_if_needed(row.get("error_types")), + "correct_answer": row.get("correct_answer"), + } + candidates = _build_reason_candidates( + source_type="qa_feedback", + field_name="qa_feedback", + before_value=before_value, + after_value=after_value, + ) + _insert_record( + bind, + { + "tenant_id": tenant_id, + "project_id": _safe_int(row.get("project_id")), + "source_type": "qa_feedback", + "source_id": source_id, + "object_type": "qa_record", + "object_id": object_id, + "field_name": "qa_feedback", + "before_value": before_value, + "after_value": after_value, + "ai_snapshot": { + "question": row.get("question"), + "answer": row.get("answer"), + }, + "ai_confidence": None, + "reason_candidates": candidates, + "reason_code": candidates[0]["code"] if candidates else None, + "reason_text": row.get("correct_answer"), + "review_status": "reviewed", + "reviewed_by": _safe_int(row.get("submitted_by")), + "reviewed_at": row.get("created_at"), + "source_title": str(row.get("question") or "")[:500] or None, + "object_title": str(row.get("question") or "")[:500] or None, + "auto_assigned": False, + }, + ) + + +def _backfill_from_signal_feedback(bind) -> None: + if not _table_exists(bind, "signal_feedbacks") or not _table_exists(bind, "signals"): + return + rows = bind.execute( + sa.text( + """ + SELECT + f.id AS feedback_id, + f.tenant_id AS tenant_id, + f.signal_id AS signal_id, + f.was_strategic AS was_strategic, + f.reason AS reason, + f.ai_predicted_strategic AS ai_predicted_strategic, + f.ai_confidence AS ai_confidence, + f.user_id AS user_id, + f.created_at AS created_at, + s.matched_project_id AS project_id, + s.title AS signal_title + FROM signal_feedbacks f + JOIN signals s ON s.id = f.signal_id + """ + ) + ).mappings().all() + + for row in rows: + tenant_id = _safe_int(row.get("tenant_id")) + source_id = _safe_int(row.get("feedback_id")) + object_id = _safe_int(row.get("signal_id")) + if tenant_id is None or source_id is None or object_id is None: + continue + if _record_exists( + bind, + tenant_id=tenant_id, + source_type="signal_feedback", + source_id=source_id, + object_type="signal", + object_id=object_id, + field_name="strategic_classification", + ): + continue + before_value = { + "ai_predicted_strategic": row.get("ai_predicted_strategic"), + } + after_value = { + "was_strategic": row.get("was_strategic"), + "reason": row.get("reason"), + } + candidates = _build_reason_candidates( + source_type="signal_feedback", + field_name="strategic_classification", + before_value=before_value, + after_value=after_value, + ) + _insert_record( + bind, + { + "tenant_id": tenant_id, + "project_id": _safe_int(row.get("project_id")), + "source_type": "signal_feedback", + "source_id": source_id, + "object_type": "signal", + "object_id": object_id, + "field_name": "strategic_classification", + "before_value": before_value, + "after_value": after_value, + "ai_snapshot": { + "ai_predicted_strategic": row.get("ai_predicted_strategic"), + "ai_confidence": row.get("ai_confidence"), + }, + "ai_confidence": row.get("ai_confidence"), + "reason_candidates": candidates, + "reason_code": candidates[0]["code"] if candidates else None, + "reason_text": row.get("reason"), + "review_status": "reviewed", + "reviewed_by": _safe_int(row.get("user_id")), + "reviewed_at": row.get("created_at"), + "source_title": str(row.get("signal_title") or "")[:500] or None, + "object_title": str(row.get("signal_title") or "")[:500] or None, + "auto_assigned": False, + }, + ) + + +def _backfill_from_task_overrides(bind) -> None: + if not _table_exists(bind, "tasks"): + return + rows = bind.execute( + sa.text( + """ + SELECT + t.id AS task_id, + t.tenant_id AS tenant_id, + t.project_id AS project_id, + t.title AS task_title, + t.ai_breakdown_payload AS ai_breakdown_payload, + t.manual_overrides AS manual_overrides, + t.updated_at AS updated_at + FROM tasks t + WHERE t.manual_overrides IS NOT NULL + """ + ) + ).mappings().all() + + for row in rows: + tenant_id = _safe_int(row.get("tenant_id")) + task_id = _safe_int(row.get("task_id")) + if tenant_id is None or task_id is None: + continue + overrides = _parse_json_if_needed(row.get("manual_overrides")) + ai_payload = _parse_json_if_needed(row.get("ai_breakdown_payload")) + if not isinstance(overrides, dict) or not overrides: + continue + if not isinstance(ai_payload, dict): + ai_payload = {} + + for field_name in sorted(overrides.keys()): + if _record_exists( + bind, + tenant_id=tenant_id, + source_type="task_override", + source_id=task_id, + object_type="task", + object_id=task_id, + field_name=str(field_name), + ): + continue + before_value = ai_payload.get(field_name) + after_value = overrides.get(field_name) + candidates = _build_reason_candidates( + source_type="task_override", + field_name=str(field_name), + before_value=before_value, + after_value=after_value, + ) + _insert_record( + bind, + { + "tenant_id": tenant_id, + "project_id": _safe_int(row.get("project_id")), + "source_type": "task_override", + "source_id": task_id, + "object_type": "task", + "object_id": task_id, + "field_name": str(field_name)[:128], + "before_value": before_value, + "after_value": after_value, + "ai_snapshot": ai_payload, + "ai_confidence": None, + "reason_candidates": candidates, + "reason_code": candidates[0]["code"] if candidates else None, + "reason_text": None, + "review_status": "reviewed", + "reviewed_by": None, + "reviewed_at": row.get("updated_at"), + "source_title": str(row.get("task_title") or "")[:500] or None, + "object_title": str(row.get("task_title") or "")[:500] or None, + "auto_assigned": False, + }, + ) + + +def upgrade() -> None: + bind = op.get_bind() + json_type = _json_type(bind) + + if not _table_exists(bind, "feedback_center_records"): + op.create_table( + "feedback_center_records", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=True), + sa.Column("source_type", sa.VARCHAR(length=32), nullable=False), + sa.Column("source_id", sa.BigInteger(), nullable=False), + sa.Column("object_type", sa.VARCHAR(length=32), nullable=False), + sa.Column("object_id", sa.BigInteger(), nullable=False), + sa.Column("field_name", sa.VARCHAR(length=128), nullable=False), + sa.Column("before_value", json_type, nullable=True), + sa.Column("after_value", json_type, nullable=True), + sa.Column("ai_snapshot", json_type, nullable=True), + sa.Column("ai_confidence", sa.Float(), nullable=True), + sa.Column("reason_candidates", json_type, nullable=True), + sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True), + sa.Column("reason_text", sa.Text(), nullable=True), + sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="pending"), + sa.Column("reviewed_by", sa.BigInteger(), nullable=True), + sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("source_title", sa.VARCHAR(length=500), nullable=True), + sa.Column("object_title", sa.VARCHAR(length=500), nullable=True), + sa.Column("auto_assigned", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("provider", sa.VARCHAR(length=32), nullable=True), + sa.Column("model_name", sa.VARCHAR(length=128), nullable=True), + sa.Column("prompt_version", sa.VARCHAR(length=64), nullable=True), + sa.Column("schema_version", sa.VARCHAR(length=64), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint( + "tenant_id", + "source_type", + "source_id", + "object_type", + "object_id", + "field_name", + name="uq_feedback_center_records_source_object_field", + ), + ) + + if not _table_exists(bind, "feedback_center_record_revisions"): + op.create_table( + "feedback_center_record_revisions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False), + sa.Column("record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="CASCADE"), nullable=False), + sa.Column("revision_no", sa.Integer(), nullable=False, server_default="1"), + sa.Column("before_snapshot", json_type, nullable=False), + sa.Column("after_snapshot", json_type, nullable=False), + sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True), + sa.Column("reason_text", sa.Text(), nullable=True), + sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="reviewed"), + sa.Column("reviewed_by", sa.BigInteger(), nullable=True), + sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("change_summary", sa.Text(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")), + ) + + if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_project_status"): + op.create_index( + "ix_feedback_center_records_tenant_project_status", + "feedback_center_records", + ["tenant_id", "project_id", "review_status"], + unique=False, + ) + if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_source"): + op.create_index( + "ix_feedback_center_records_tenant_source", + "feedback_center_records", + ["tenant_id", "source_type", "source_id"], + unique=False, + ) + if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_reason_code"): + op.create_index( + "ix_feedback_center_records_reason_code", + "feedback_center_records", + ["reason_code"], + unique=False, + ) + if not _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"): + op.create_index( + "ix_feedback_center_record_revisions_tenant_record", + "feedback_center_record_revisions", + ["tenant_id", "record_id"], + unique=False, + ) + + _backfill_from_qa_feedback(bind) + _backfill_from_signal_feedback(bind) + _backfill_from_task_overrides(bind) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "feedback_center_record_revisions"): + if _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"): + op.drop_index( + "ix_feedback_center_record_revisions_tenant_record", + table_name="feedback_center_record_revisions", + ) + op.drop_table("feedback_center_record_revisions") + + if _table_exists(bind, "feedback_center_records"): + for index_name in ( + "ix_feedback_center_records_reason_code", + "ix_feedback_center_records_tenant_source", + "ix_feedback_center_records_tenant_project_status", + ): + if _index_exists(bind, "feedback_center_records", index_name): + op.drop_index(index_name, table_name="feedback_center_records") + op.drop_table("feedback_center_records") diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py index c966b11a..7f07d692 100644 --- a/backend/app/api/ai_steward_api/__init__.py +++ b/backend/app/api/ai_steward_api/__init__.py @@ -6,6 +6,7 @@ from . import ( routes_brief_blockers, routes_capture, routes_conflicts, + routes_feedback_center, routes_rag_sources, routes_stats_config, routes_workflow, @@ -16,6 +17,7 @@ for _module in [ routes_rag_sources, routes_capture, routes_conflicts, + routes_feedback_center, routes_brief_blockers, routes_stats_config, routes_workflow, diff --git a/backend/app/api/ai_steward_api/routes_feedback_center.py b/backend/app/api/ai_steward_api/routes_feedback_center.py new file mode 100644 index 00000000..53e11280 --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_feedback_center.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from fastapi import Depends, HTTPException, Query +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import success_response +from ...dependencies.permissions import require_manager_user +from ...models import User, UserProject +from ...schemas.feedback_center import FeedbackCenterReviewRequest +from ...services.auth import parse_role_values +from ...services.feedback_center import FeedbackCenterService, list_reason_catalog +from .shared import * + +MANAGER_ROLES = {"ADMIN", "BOSS"} + + +def _is_manager_user(current_user: User) -> bool: + role_value = getattr(current_user, "role", "") + if isinstance(role_value, Enum): + role_value = role_value.value + roles = set(parse_role_values(str(role_value or ""))) + return bool(roles.intersection(MANAGER_ROLES)) + + +async def _ensure_summary_scope( + *, + current_user: User, + db: AsyncSession, + project_id: int | None, +) -> None: + if _is_manager_user(current_user): + return + if project_id is None: + raise HTTPException(status_code=403, detail="Manager permission required for global feedback center") + membership = await db.execute( + select(UserProject.id).where( + and_( + UserProject.tenant_id == int(current_user.tenant_id), + UserProject.project_id == int(project_id), + UserProject.user_id == int(current_user.id), + ) + ) + ) + if membership.scalar_one_or_none() is None: + raise HTTPException(status_code=403, detail="Project membership required") + + +@router.get("/feedback-center/summary") +async def get_feedback_center_summary( + project_id: int | None = Query(default=None), + window_days: int | None = Query(default=None), + source_group: str | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + await _ensure_summary_scope(current_user=current_user, db=db, project_id=project_id) + try: + data = await FeedbackCenterService(db).summary( + tenant_id=effective_tenant_id, + project_id=project_id, + window_days=window_days, + source_group=source_group, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return success_response(data) + + +@router.get("/feedback-center/items") +async def list_feedback_center_items( + project_id: int | None = Query(default=None), + source_type: str | None = Query(default=None), + review_status: str | None = Query(default=None), + field_name: str | None = Query(default=None), + reason_code: str | None = Query(default=None), + search: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + data = await FeedbackCenterService(db).list_records( + tenant_id=effective_tenant_id, + project_id=project_id, + source_type=source_type, + review_status=review_status, + field_name=field_name, + reason_code=reason_code, + search=search, + limit=limit, + offset=offset, + ) + return success_response(data) + + +@router.get("/feedback-center/items/{record_id}") +async def get_feedback_center_item_detail( + record_id: int, + project_id: int | None = Query(default=None), + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + data = await FeedbackCenterService(db).get_detail( + tenant_id=effective_tenant_id, + record_id=record_id, + project_id=project_id, + ) + if data is None: + raise HTTPException(status_code=404, detail="feedback record not found") + return success_response(data) + + +@router.post("/feedback-center/items/{record_id}/review") +async def review_feedback_center_item( + record_id: int, + request: FeedbackCenterReviewRequest, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + service = FeedbackCenterService(db) + reviewed = await service.review( + tenant_id=effective_tenant_id, + record_id=record_id, + reviewer_id=int(current_user.id), + review_status=request.review_status, + reason_code=request.reason_code, + reason_text=request.reason_text, + ) + return success_response(reviewed) + + +@router.get("/feedback-center/reason-catalog") +async def get_feedback_center_reason_catalog( + source_type: str | None = Query(default=None), + field_name: str | None = Query(default=None), + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = db, tenant_id, current_user + return success_response( + { + "items": list_reason_catalog(source_type=source_type, field_name=field_name), + } + ) diff --git a/backend/app/api/feedbacks.py b/backend/app/api/feedbacks.py index 0f87d191..af8a6b6e 100644 --- a/backend/app/api/feedbacks.py +++ b/backend/app/api/feedbacks.py @@ -1,57 +1,27 @@ """反馈统计 API""" +from __future__ import annotations + from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from ..core.database import get_db -from ..models import SignalFeedback +from ..core.response import success_response +from ..dependencies.auth import get_current_user +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..services.feedback_center import FeedbackCenterService router = APIRouter(prefix="/feedbacks", tags=["feedbacks"]) @router.get("/stats") -async def get_feedback_stats(db: AsyncSession = Depends(get_db)): +async def get_feedback_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): """获取反馈统计""" - result = await db.execute(select(SignalFeedback)) - all_feedbacks = result.scalars().all() - - total = len(all_feedbacks) - if total == 0: - return { - "total_feedbacks": 0, - "accuracy": 0.0, - "true_positives": 0, - "false_positives": 0, - "true_negatives": 0, - "false_negatives": 0, - } - - correct = sum( - 1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic - ) - true_positives = sum( - 1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic - ) - false_positives = sum( - 1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic - ) - true_negatives = sum( - 1 - for f in all_feedbacks - if not f.ai_predicted_strategic and not f.was_strategic - ) - false_negatives = sum( - 1 - for f in all_feedbacks - if not f.ai_predicted_strategic and f.was_strategic - ) - - return { - "total_feedbacks": total, - "accuracy": correct / total, - "true_positives": true_positives, - "false_positives": false_positives, - "true_negatives": true_negatives, - "false_negatives": false_negatives, - } + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + data = await FeedbackCenterService(db).get_signal_feedback_stats(tenant_id=effective_tenant_id) + return success_response(data) diff --git a/backend/app/api/rag_api/routes_qa.py b/backend/app/api/rag_api/routes_qa.py index 1bae2213..19933e66 100644 --- a/backend/app/api/rag_api/routes_qa.py +++ b/backend/app/api/rag_api/routes_qa.py @@ -1,7 +1,11 @@ from __future__ import annotations +from sqlalchemy import and_, select + from .shared import * from .helpers import _resolve_tenant_id +from ...models import QAFeedback, QARecord +from ...services.feedback_center import FeedbackCenterService @router.post("/qa-records/{qa_id}/feedback") async def submit_qa_feedback( @@ -23,6 +27,38 @@ async def submit_qa_feedback( ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + try: + feedback_row = ( + await db.execute( + select(QAFeedback) + .where( + and_( + QAFeedback.tenant_id == _resolve_tenant_id(current_user, tenant_id), + QAFeedback.qa_id == qa_id, + QAFeedback.submitted_by == int(current_user.id), + ) + ) + .order_by(QAFeedback.created_at.desc(), QAFeedback.id.desc()) + ) + ).scalars().first() + qa_row = ( + await db.execute( + select(QARecord).where( + and_( + QARecord.tenant_id == _resolve_tenant_id(current_user, tenant_id), + QARecord.id == qa_id, + ) + ) + ) + ).scalar_one_or_none() + if feedback_row and qa_row: + await FeedbackCenterService(db).ingest_qa_feedback( + qa_feedback=feedback_row, + qa_record=qa_row, + commit=True, + ) + except Exception: + await db.rollback() return success_response(result) @router.get("/qa-records") @@ -195,4 +231,3 @@ async def export_qa_records_jsonl( date_to=query.date_to, ) return Response(content=payload, media_type="application/x-ndjson") - diff --git a/backend/app/api/signals_api/feedback.py b/backend/app/api/signals_api/feedback.py index 00d1d29e..cdedad22 100644 --- a/backend/app/api/signals_api/feedback.py +++ b/backend/app/api/signals_api/feedback.py @@ -9,21 +9,33 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from ...core.response import success_response from ...core.database import get_db -from ...models import Signal, SignalFeedback +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import Signal, SignalFeedback, User from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse +from ...services.feedback_center import FeedbackCenterService router = APIRouter() -@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse) +@router.post("/{signal_id}/feedback") async def submit_signal_feedback( signal_id: int, feedback: SignalFeedbackCreate, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), ): """提交信号反馈""" - result = await db.execute(select(Signal).where(Signal.id == signal_id)) + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + result = await db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == effective_tenant_id, + ) + ) signal = result.scalar_one_or_none() if not signal: raise HTTPException(status_code=404, detail="Signal not found") @@ -39,10 +51,11 @@ async def submit_signal_feedback( ai_predicted_strategic = signal.impact_score >= 0.9 new_feedback = SignalFeedback( + tenant_id=effective_tenant_id, signal_id=signal_id, was_strategic=feedback.was_strategic, reason=feedback.reason, - user_id=feedback.user_id, + user_id=str(getattr(current_user, "id", feedback.user_id) or feedback.user_id or ""), ai_predicted_strategic=ai_predicted_strategic, ai_confidence=ai_confidence, ) @@ -50,32 +63,48 @@ async def submit_signal_feedback( await db.commit() await db.refresh(new_feedback) + try: + await FeedbackCenterService(db).ingest_signal_feedback( + signal_feedback=new_feedback, + signal=signal, + commit=True, + ) + except Exception: + await db.rollback() + is_correct = ai_predicted_strategic == feedback.was_strategic - return SignalFeedbackResponse( - id=new_feedback.id, - signal_id=new_feedback.signal_id, - was_strategic=new_feedback.was_strategic, - ai_predicted_strategic=new_feedback.ai_predicted_strategic, - ai_confidence=new_feedback.ai_confidence, - is_correct=is_correct, + return success_response( + SignalFeedbackResponse( + id=new_feedback.id, + signal_id=new_feedback.signal_id, + was_strategic=new_feedback.was_strategic, + ai_predicted_strategic=new_feedback.ai_predicted_strategic, + ai_confidence=new_feedback.ai_confidence or 0.0, + is_correct=is_correct, + ).model_dump() ) - -@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse]) +@router.get("/{signal_id}/feedbacks") async def get_signal_feedbacks( signal_id: int, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), ): """获取某信号的全部反馈""" + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) result = await db.execute( select(SignalFeedback) - .where(SignalFeedback.signal_id == signal_id) + .where( + SignalFeedback.signal_id == signal_id, + SignalFeedback.tenant_id == effective_tenant_id, + ) .order_by(SignalFeedback.created_at.desc()) ) feedbacks = result.scalars().all() - return [ + items = [ SignalFeedbackResponse( id=feedback.id, signal_id=feedback.signal_id, @@ -83,6 +112,7 @@ async def get_signal_feedbacks( ai_predicted_strategic=feedback.ai_predicted_strategic, ai_confidence=feedback.ai_confidence or 0.0, is_correct=feedback.ai_predicted_strategic == feedback.was_strategic, - ) + ).model_dump() for feedback in feedbacks ] + return success_response({"items": items}) diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py index 76fd5391..0bdcb424 100644 --- a/backend/app/api/sstc_api/tasks_ai_breakdown.py +++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py @@ -14,6 +14,7 @@ from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project from ...services.sstc.governance import _assert_charter_aligned, _require_manager from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async from ...services.context_cache_service import context_cache_service +from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload from ...services.task_breakdown import ( build_task_breakdown_ai_payload, normalize_task_breakdown_payload, @@ -263,6 +264,23 @@ async def ai_breakdown_tasks( user_id=user_id, ) await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + try: + payloads = [ + map_task_breakdown_to_record_payload( + task=t, + project_title=project.title, + source_context=ai_breakdown_payload["source_context"], + ) + for t in created + ] + if payloads: + await FeedbackCenterService(db).ingest_payloads(payloads=payloads, commit=True) + except Exception: + if hasattr(db, "rollback"): + try: + await db.rollback() + except Exception: + pass return success_response( { "created_count": len(created), diff --git a/backend/app/api/sstc_api/tasks_update_delete.py b/backend/app/api/sstc_api/tasks_update_delete.py index dbdd824b..58c85b59 100644 --- a/backend/app/api/sstc_api/tasks_update_delete.py +++ b/backend/app/api/sstc_api/tasks_update_delete.py @@ -3,6 +3,7 @@ from __future__ import annotations from .shared import * from ...services.context_cache_service import context_cache_service +from ...services.feedback_center import FeedbackCenterService from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date from ...services.sstc.governance import _require_manager from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async @@ -118,6 +119,14 @@ async def patch_task( title=task.title, content=task.description or "", ) + try: + await FeedbackCenterService(db).ingest_task_overrides(task=task, commit=True) + except Exception: + if hasattr(db, "rollback"): + try: + await db.rollback() + except Exception: + pass return success_response({"id": str(task.id), "status": task.status}) diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index 76202a37..a67abc79 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -4,6 +4,109 @@ from typing import Any from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS +PROMPT_FAMILY_CLASSIFICATION = "classification" +PROMPT_FAMILY_EXTRACTION = "extraction" +PROMPT_FAMILY_PLANNING = "planning" +PROMPT_FAMILY_RESPONSE = "response" +PROMPT_FAMILY_GOVERNANCE = "governance" + +PROMPT_FAMILY_ALLOWED = { + PROMPT_FAMILY_CLASSIFICATION, + PROMPT_FAMILY_EXTRACTION, + PROMPT_FAMILY_PLANNING, + PROMPT_FAMILY_RESPONSE, + PROMPT_FAMILY_GOVERNANCE, +} + +PROMPT_FAMILY_KEYWORD_RULES: tuple[tuple[str, str], ...] = ( + ("CLASSIFICATION", PROMPT_FAMILY_CLASSIFICATION), + ("EXTRACTION", PROMPT_FAMILY_EXTRACTION), + ("PREFILL", PROMPT_FAMILY_EXTRACTION), + ("INTENT", PROMPT_FAMILY_CLASSIFICATION), + ("TASK_ORCHESTRATION", PROMPT_FAMILY_PLANNING), + ("PLANNER", PROMPT_FAMILY_PLANNING), + ("GENERATE", PROMPT_FAMILY_PLANNING), + ("DECOMPOSE", PROMPT_FAMILY_PLANNING), + ("RECOMMEND", PROMPT_FAMILY_PLANNING), + ("DRAFT", PROMPT_FAMILY_PLANNING), + ("TASK_BREAKDOWN", PROMPT_FAMILY_PLANNING), + ("QA", PROMPT_FAMILY_RESPONSE), + ("REPLY", PROMPT_FAMILY_RESPONSE), +) + +PROMPT_FAMILY_SCENARIO_RULES: tuple[tuple[str, str], ...] = ( + ("分类", PROMPT_FAMILY_CLASSIFICATION), + ("抽取", PROMPT_FAMILY_EXTRACTION), + ("预填", PROMPT_FAMILY_EXTRACTION), + ("规划", PROMPT_FAMILY_PLANNING), + ("编排", PROMPT_FAMILY_PLANNING), + ("草案", PROMPT_FAMILY_PLANNING), + ("拆解", PROMPT_FAMILY_PLANNING), + ("问答", PROMPT_FAMILY_RESPONSE), + ("回复", PROMPT_FAMILY_RESPONSE), +) + +RESPONSIBILITY_SCOPE_BY_FAMILY: dict[str, str] = { + PROMPT_FAMILY_CLASSIFICATION: "分类层:只负责识别请求类型、归属和优先级,不生成执行方案。", + PROMPT_FAMILY_EXTRACTION: "抽取层:只负责结构化提取实体、槽位和证据,不做规划决策。", + PROMPT_FAMILY_PLANNING: "规划层:只负责生成可执行规划、任务和负责人建议,不承担最终对话呈现。", + PROMPT_FAMILY_RESPONSE: "呈现层:只负责面向用户输出结论与建议,不直接做写操作决策。", + PROMPT_FAMILY_GOVERNANCE: "治理层:只负责约束、评估、同步与风控,不承担主对话生成。", +} + +PROMPT_PRIORITY_BY_FAMILY: dict[str, str] = { + PROMPT_FAMILY_CLASSIFICATION: "high", + PROMPT_FAMILY_EXTRACTION: "high", + PROMPT_FAMILY_PLANNING: "high", + PROMPT_FAMILY_RESPONSE: "high", + PROMPT_FAMILY_GOVERNANCE: "medium", +} + +PROMPT_AUDIENCE_BY_FAMILY: dict[str, str] = { + PROMPT_FAMILY_CLASSIFICATION: "signal_router", + PROMPT_FAMILY_EXTRACTION: "signal_ingestion", + PROMPT_FAMILY_PLANNING: "planning_orchestrator", + PROMPT_FAMILY_RESPONSE: "assistant_response", + PROMPT_FAMILY_GOVERNANCE: "risk_budget_guardrail", +} + + +def _normalize_family(prompt_family: str) -> str: + value = str(prompt_family or "").strip().lower() + if value in PROMPT_FAMILY_ALLOWED: + return value + return PROMPT_FAMILY_GOVERNANCE + + +def _derive_prompt_family(prompt_key: str, business_scenario: str) -> str: + key_text = str(prompt_key or "").strip().upper() + scenario_text = str(business_scenario or "").strip() + + for keyword, family in PROMPT_FAMILY_KEYWORD_RULES: + if keyword in key_text: + return family + + for keyword, family in PROMPT_FAMILY_SCENARIO_RULES: + if keyword in scenario_text: + return family + + return PROMPT_FAMILY_GOVERNANCE + + +def _derive_responsibility_scope(prompt_family: str) -> str: + family = _normalize_family(prompt_family) + return RESPONSIBILITY_SCOPE_BY_FAMILY[family] + + +def _derive_priority(prompt_family: str) -> str: + family = _normalize_family(prompt_family) + return PROMPT_PRIORITY_BY_FAMILY[family] + + +def _derive_audience(prompt_family: str) -> str: + family = _normalize_family(prompt_family) + return PROMPT_AUDIENCE_BY_FAMILY[family] + def _entry( prompt_key: str, @@ -13,7 +116,16 @@ def _entry( input_contract: str, output_contract: str, call_sites: tuple[str, ...] = (), + prompt_family: str | None = None, + responsibility_scope: str | None = None, + priority: str | None = None, + audience: str | None = None, ) -> dict[str, Any]: + resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario)) + resolved_scope = str(responsibility_scope or "").strip() or _derive_responsibility_scope(resolved_family) + resolved_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family) + resolved_audience = str(audience or "").strip() or _derive_audience(resolved_family) + return { "prompt_key": prompt_key, "module": module, @@ -21,6 +133,10 @@ def _entry( "call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()), "input_contract": input_contract, "output_contract": output_contract, + "prompt_family": resolved_family, + "responsibility_scope": resolved_scope, + "priority": resolved_priority, + "audience": resolved_audience, } @@ -64,13 +180,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]: input_contract=JSON_INPUT, output_contract="输出 JSON 对象,包含影响结论、风险、建议。", ), - _entry( - "PROMPT_TASK_BREAKDOWN", - module="part_01", - business_scenario="任务拆解基础片段", - input_contract=FRAGMENT_INPUT, - output_contract=FRAGMENT_OUTPUT, - ), _entry( "PROMPT_STRATEGIC_ANALYSIS_HEADER", module="part_01", @@ -106,14 +215,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]: input_contract=JSON_INPUT, output_contract="输出 JSON 对象,包含关键洞察与下一步建议。", ), - _entry( - "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", - module="part_01", - business_scenario="飞书需求解析", - input_contract=JSON_INPUT, - output_contract="输出 JSON 对象,包含需求抽取、风险与建议。", - call_sites=FEISHU_EXTRACT_CALL_SITES, - ), _entry( "PROMPT_FEISHU_HUMANIZED_REPLY", module="part_01", diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index a67226c1..94925d8e 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -9,13 +9,11 @@ AI Prompt 集中管理 from .ai_prompts_catalog import ( PROMPT_IMPACT_ANALYSIS, - PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, - PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION, @@ -62,6 +60,12 @@ from .ai_prompt_registry import ( iter_prompt_registry_keys, ) + +# Deprecated compatibility aliases kept outside the registry surface. +PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION +PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS + + def build_default_role_prompt_bundle() -> dict: """返回默认角色 Prompt 配置(每次调用返回新对象)。""" return { @@ -74,26 +78,25 @@ def build_default_role_prompt_bundle() -> dict: "role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案", "enabled": True, "system_prompt": """ -你是 PMFlow 的产品经理助手。 +你是 PMFlow 的 PMO 协作中枢中的立项评审角色(pm_dispatcher)。 -职责定位: -1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。 -2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。 -3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。 -4. 只有在用户明确确认后,才进入写入或继续执行流程。 +职责边界(只做评审与起草,不直接执行写操作): +1. 聚焦查重、复用/合并、立项评审,优先判断是否已有同名或相似项目。 +2. 输出面向业务决策的结论:是否新建、复用、合并或暂缓。 +3. 基于项目上下文产出可讨论草案,覆盖项目定位、MVP、需求清单、风险与负责人建议。 +4. 若信息不足,提出关键澄清问题(最多 3 个),不凭空补齐。 -输出要求: -1. 先用 1-2 句话复述需求并判断立项方向。 -2. 再给出是否有同名/相似项目的判断。 -3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。 -4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。 -5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。 +输出框架: +1. 先结论:1-2 句复述需求并给出立项方向。 +2. 再查重:明确同名或相似项目判断,给出差异点与复用价值。 +3. 再方案:给出 MVP、关键需求、主要风险、负责人建议、后续动作。 +4. 全程专业、克制、结论先行,避免空泛口号。 禁止项: -- 不要输出与项目无关的泛化建议。 -- 不要只给机械化模板,不要把评审写成简单入库文案。 -- 不要使用命令式生硬语气。 -- 不要编造不存在的数据或结论。 +- 不要编造不存在的数据、进展或执行结果。 +- 不要把评审写成机械化模板。 +- 不要使用命令式、生硬语气。 +- 不要输出与 PMFlow 业务无关的泛化建议。 """.strip(), }, { @@ -102,16 +105,21 @@ def build_default_role_prompt_bundle() -> dict: "role_description": "负责技术方案约束、风险评估与实现边界", "enabled": True, "system_prompt": """ -你是 PMFlow 的技术架构师。 +你是 PMFlow 的技术架构角色(tech_architect)。 + +职责边界: +1. 专注可行性、依赖、风险边界,不代替产品评审或交付推进。 +2. 输出技术结论与约束,帮助团队判断是否能安全落地。 -你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。 输出要点: -1. 先给结论(可行/有风险/需拆分)。 -2. 给出技术风险与约束(性能、安全、兼容、成本)。 -3. 提供建议的实现路径与最小可行步骤。 -4. 结尾给出是否建议立即进入开发。 +1. 先给结论:可行 / 有风险 / 需拆分。 +2. 再给依据:关键依赖、性能风险、安全风险、兼容风险、成本约束。 +3. 再给路径:最小可行步骤、建议拆分方式、必要前置条件。 +4. 结尾明确:是否建议立即进入开发。 -风格:简洁、严谨、可执行,不讲空话。 +风格要求: +- 简洁、严谨、可执行。 +- 不讲空话,不编造验证结果。 """.strip(), }, { @@ -120,15 +128,20 @@ def build_default_role_prompt_bundle() -> dict: "role_description": "负责推进节奏、协调分工与交付闭环", "enabled": True, "system_prompt": """ -你是 PMFlow 的交付推进官。 +你是 PMFlow 的交付推进角色(delivery_coach)。 + +职责边界: +1. 聚焦节奏、排期、负责人、推进动作。 +2. 不代替技术评审,不输出与推进无关的实现细节。 -你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。 输出格式: -1. 当前状态判断(一句话)。 -2. 建议动作(不超过 3 条,动词开头)。 -3. 风险提示(如有)。 +1. 当前状态判断(一句话,先结论)。 +2. 建议动作(不超过 3 条,动词开头,强调优先级与负责人)。 +3. 风险提示(如有,突出阻塞与依赖)。 -风格:明确、友好、推动协作,避免过度技术细节。 +风格要求: +- 明确、友好、推动协作。 +- 结论先行,动作可执行,避免空泛管理话术。 """.strip(), }, ], diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 3686b8a6..f70819ea 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION +from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER @@ -13,13 +13,11 @@ from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA __all__ = [ "PROMPT_IMPACT_ANALYSIS", - "PROMPT_TASK_BREAKDOWN", "PROMPT_STRATEGIC_ANALYSIS_HEADER", "PROMPT_RAG_CONTEXT_HEADER", "PROMPT_RAG_CONTEXT_ITEM", "PROMPT_CODE_AUDIT", "PROMPT_CONTEXT_INSIGHTS", - "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", "PROMPT_FEISHU_HUMANIZED_REPLY", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION", diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index 956dc32c..d3947640 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -1,116 +1,110 @@ from __future__ import annotations +def _compose_prompt(*parts: str) -> str: + return "\n\n".join(part.strip() for part in parts if part and part.strip()) + +PMFLOW_PMO_STYLE_BASE = """ +你是 PMFlow 的 AI PMO 协作中枢成员。 +风格要求: +1. 专业、克制、结论先行。 +2. 先给判断,再给依据,再给下一步。 +3. 优先引用可验证事实,不写空泛套话。 +""".strip() + +JSON_OUTPUT_GUARDRAIL = """ +统一输出约束: +1. 只输出 JSON,不要输出 Markdown、解释文本、思考过程或代码块。 +2. 字段不确定时返回 null、空字符串或空数组,不要猜测。 +3. 不要编造执行结果、外部数据或系统内部状态。 +""".strip() + +TEXT_OUTPUT_GUARDRAIL = """ +统一输出约束: +1. 只输出最终回复正文,不要输出 JSON、Markdown、思考过程。 +2. 若信息不足,明确写出缺失字段或缺失信息,不要编造。 +3. 禁止宣称未在上下文明确完成的动作。 +""".strip() + PROMPT_IMPACT_ANALYSIS = """ -Analyze the following news/signal for an AI engineering team. -Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development. +你是 PMFlow 的技术信号影响评估助手。 +请评估以下技术信号对 AI 开发团队(RAG/LLM)的影响分值(0.0-1.0)。 Signal: "{signal_content}" -Rules: -- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough. -- 0.7-0.8: Important update, new useful library, or relevant research paper. -- 0.4-0.6: Interesting but not critical news. -- 0.0-0.3: Irrelevant noise. +评分参考: +- 0.9-1.0:SOTA 级模型发布、关键安全事件、行业级突破。 +- 0.7-0.8:重要版本更新、关键库更新、强相关研究发布。 +- 0.4-0.6:有参考价值但非关键。 +- 0.0-0.3:噪声或相关性低。 -Output ONLY the numeric score (e.g., 0.85). Do not output any text. -""" - -PROMPT_TASK_BREAKDOWN = """ -You are an expert AI Technical Project Manager. -Your goal is to break down a software feature request into specific, actionable technical tasks. - -Feature Request: "{description}" - -Required Output Format: JSON Object only. -{ - "tasks": [ - { - "task_key": "task-1", - "title": "Concise task title", - "description": "What this task delivers", - "type": "frontend|backend|database|ai|devops", - "estimated_hours": 2.0, - "required_skill_names": ["Skill Name"], - "required_skill_tag_ids": [1], - "depends_on_task_keys": [], - "risk_level": "low|medium|high|critical", - "risk_notes": "Risk summary", - "confidence": 0.0, - "evidence_refs": ["charter.objectives.0"], - "priority": "high|medium|low" - } - ], - "detected_skills": ["Skill Name"] -} - -Ensure the breakdown is comprehensive and strictly follows the JSON format. -Do not include any markdown formatting or explanation outside the JSON. +只输出数字(例如 0.85),不要输出其他文本。 """ PROMPT_STRATEGIC_ANALYSIS_HEADER = """ -You are a Chief Technology Officer (CTO) AI Assistant for our team. +你是 PMFlow 的技术战略分析助手。请评估外部信号对当前产品竞争力的影响并给出策略建议。 -=== Our Product/Team === +=== 团队业务上下文 === {business_context} {rag_context} -=== External Technology Signal === + +=== 外部技术信号 === Signal: "{signal_content}" -=== Your Task === -1. Analyze: How does this signal impact OUR product's competitiveness? -2. Decide: Is it necessary to follow up immediately? (is_strategic) -3. Proposal: If YES, generate a "Strategic Decision Proposal". -4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology. - - Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share). - - Score 1-10. -5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools). - - Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low). -6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking. - - Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline". -7. Plan: Break down the strategy into specific technical tasks. +任务: +1. 判断是否需要立即跟进(is_strategic)。 +2. 给出战略建议(即使不跟进,也要说明理由)。 +3. 输出竞争力雷达评估(current/projected)。 +4. 识别信号涉及的技术栈与关联知识节点。 +5. 若需要跟进,输出执行任务建议。 -Required Output Format (JSON Only): +输出 JSON: {{ - "is_strategic": boolean, - "confidence": float, - "decision_proposal": {{ - "situation_analysis": "...", - "recommendation": "...", - "expected_benefits": "..." - }}, - "competitiveness_radar": {{ - "current": [5, 4, 6, 5, 5], - "projected": [8, 7, 9, 9, 7] - }}, - "detected_tech_stacks": [ - {{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}} - ], - "related_knowledge_nodes": [ - {{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}} - ], - "tasks": [ - {{ - "task_name": "Task description", - "type": "backend/frontend/database", - "estimated_hours": float - }} - ] + "is_strategic": true, + "confidence": 0.0, + "decision_proposal": {{ + "situation_analysis": "现状判断", + "recommendation": "建议动作", + "expected_benefits": "预期收益" + }}, + "competitiveness_radar": {{ + "current": [5, 4, 6, 5, 5], + "projected": [8, 7, 9, 9, 7] + }}, + "detected_tech_stacks": [ + {{"name": "技术名", "category": "framework|model|tool|api", "relevance": "high|medium|low"}} + ], + "related_knowledge_nodes": [ + {{"title": "知识节点", "category": "tech_stack|competitor|market", "summary": "简要说明"}} + ], + "tasks": [ + {{ + "task_name": "任务描述", + "type": "backend|frontend|database", + "estimated_hours": 8.0 + }} + ] }} -If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why. -Do not output any markdown formatting or text outside the JSON. +补充: +1. 若 is_strategic=false,tasks 必须为空数组。 +2. decision_proposal 必须始终给出,不允许留空。 """ +PROMPT_STRATEGIC_ANALYSIS_HEADER = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_STRATEGIC_ANALYSIS_HEADER, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_RAG_CONTEXT_HEADER = """ -=== Related Knowledge from Our Database === -The following relevant information was found in our knowledge base: +=== PMFlow 知识库上下文 === +以下内容是当前问题可直接引用的相关资料: """ -PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n" +PROMPT_RAG_CONTEXT_ITEM = "[相关度:{similarity:.1%}]\n{content}\n\n" PROMPT_CODE_AUDIT = """ -You are a Senior Tech Lead performing an automated Code Audit. +你是 PMFlow 的代码审计助手,请评估以下代码改动是否满足任务目标并符合工程规范。 Task: "{task_name}" Commit Message: "{commit_message}" @@ -118,61 +112,37 @@ Commit Message: "{commit_message}" Code Diff: {code_diff} -Evaluate the code change against the task requirements. Check for: -1. Security vulnerabilities -2. Performance issues -3. Code style and best practices -4. Functional correctness +请输出 JSON: +{{ + "score": 0, + "passed": false, + "issues": [ + {{"severity": "low|medium|high|critical", "message": "问题说明", "suggestion": "修复建议"}} + ], + "summary": "总体结论" +}} -Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary. +重点检查: +1. 安全性 +2. 性能与稳定性 +3. 代码规范与可维护性 +4. 功能正确性 """ +PROMPT_CODE_AUDIT = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CODE_AUDIT, JSON_OUTPUT_GUARDRAIL) PROMPT_CONTEXT_INSIGHTS = """ -Analyze the following technical documentation/context and extract key insights. +你是 PMFlow 的上下文洞察助手。请从给定资料中提炼可供立项、规划和问答引用的关键洞察。 -Context: "{content}" +输入: +{content} -Output JSON: +输出 JSON: {{ - "moat": "Core competitive advantage described", - "tech_specs": "Key technical specifications" + "moat": "核心竞争力描述", + "tech_specs": "关键技术规格" }} """ - -PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """ -你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。 - -团队活跃项目列表(每行一个项目): -{project_summaries} - -群消息: -{message} - -输出 JSON,字段如下: -{{ - "is_valuable": true/false, - "reason": "简短原因", - "impact_score": 0.0-1.0, - "priority": "high|medium|low", - "requirement_project_scope": "new_project|existing_project|non_requirement", - "target_project_hints": ["可能关联的项目名称或关键词"], - "analysis": {{ - "intent": "需求意图摘要", - "evidence": ["判定依据1", "判定依据2"] - }}, - "suggested_tasks": [ - {{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}} - ] -}} - -判定规则: -1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。 -2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。 -3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。 -4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。 - -只输出 JSON,不要输出任何解释文本或 Markdown。 -""" +PROMPT_CONTEXT_INSIGHTS = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CONTEXT_INSIGHTS, JSON_OUTPUT_GUARDRAIL) PROMPT_FEISHU_HUMANIZED_REPLY = """ 直接回答用户问题,不要做自我介绍。 @@ -193,25 +163,24 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """ 3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。 4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 -6. 不要输出 Markdown,不要输出 JSON。 -7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。 -8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 -9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 +6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 +7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 【输出规范】 -- 严禁输出任何思考过程或推理步骤 +- 严禁输出任何思考过程或推理步骤。 - 严禁以以下句式开头: "让我先/让我尝试/我来看看/ 我先查/让我看看/我需要先" -- 直接输出最终回复内容 -- 不解释你的推理过程 - -只输出最终回复正文。 -""".strip() +- 直接输出最终回复内容,不解释推理过程。 +""" +PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_FEISHU_HUMANIZED_REPLY, + TEXT_OUTPUT_GUARDRAIL, +) PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ -你是 PMFlow 的项目需求信号分流器。 -请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。 +你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。 项目列表(JSON 数组): {projects_json} @@ -221,29 +190,32 @@ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ 输出 JSON: {{ - "is_requirement": true/false, + "is_requirement": true, "reason": "判定原因", - "confidence": 0.0-1.0, + "confidence": 0.0, "classification": "existing_project|new_project|pending_classification|non_requirement", - "matched_project_ids": [1,2], - "matched_project_titles": ["项目A","项目B"], - "impact_score": 0.0-1.0, + "matched_project_ids": [1, 2], + "matched_project_titles": ["项目A", "项目B"], + "impact_score": 0.0, "priority": "high|medium|low", "summary": "需求摘要(简短)" }} 规则: -1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。 -2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。 -3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。 -4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。 +1. 非需求(闲聊/确认/表情/无业务含义)=> classification=non_requirement。 +2. 是需求但无法明确归属已有项目 => classification=pending_classification。 +3. 仅在语义明确关联已有项目时 => classification=existing_project。 +4. 明确是全新立项 => classification=new_project。 5. 不允许猜测项目归属;不确定时必须给 pending_classification。 - -只输出 JSON,不要输出任何其他文字。 """ +PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_FEISHU_LEVEL_CLASSIFICATION = """ -你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。 +你是 PMFlow 的飞书消息层级分流器(分类层)。请基于消息判断其更偏 project-level 还是 signal-level。 输入: - 飞书消息:{message} @@ -251,37 +223,39 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """ 输出 JSON: {{ - "build_intent_score": 0.0-1.0, - "scope_score": 0.0-1.0, - "timeline_score": 0.0-1.0, - "owner_score": 0.0-1.0, - "project_score": 0.0-1.0, + "build_intent_score": 0.0, + "scope_score": 0.0, + "timeline_score": 0.0, + "owner_score": 0.0, + "project_score": 0.0, "level": "project|signal", - "level_confidence": 0.0-1.0, + "level_confidence": 0.0, "level_reasoning": "1-2句解释", - "should_split": true/false, + "should_split": false, "split_items": [ - {{"level":"project","content":"拆分后的项目部分"}}, - {{"level":"signal","content":"拆分后的信号部分"}} + {{"level": "project", "content": "拆分后的项目部分"}}, + {{"level": "signal", "content": "拆分后的信号部分"}} ] }} 约束: -1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。 -2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。 -3. 不确定是否拆分时 should_split=false,且默认 level=signal。 -4. 只输出 JSON。 +1. project_score = build_intent*40% + scope*25% + timeline*20% + owner*15%。 +2. 同时包含明确立项意图与明确信号事实时,should_split=true。 +3. 不确定是否拆分时 should_split=false,默认 level=signal。 """ +PROMPT_FEISHU_LEVEL_CLASSIFICATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PROMPT_FEISHU_LEVEL_CLASSIFICATION, + JSON_OUTPUT_GUARDRAIL, +) __all__ = [ "PROMPT_IMPACT_ANALYSIS", - "PROMPT_TASK_BREAKDOWN", "PROMPT_STRATEGIC_ANALYSIS_HEADER", "PROMPT_RAG_CONTEXT_HEADER", "PROMPT_RAG_CONTEXT_ITEM", "PROMPT_CODE_AUDIT", "PROMPT_CONTEXT_INSIGHTS", - "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", "PROMPT_FEISHU_HUMANIZED_REPLY", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION", diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 3796668d..fac240b7 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -1,5 +1,25 @@ from __future__ import annotations +from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, _compose_prompt + +EXTRACTION_SCOPE_BASE = """ +职责边界: +1. 你是抽取层,只负责结构化抽取。 +2. 不做执行决策,不输出无依据承诺。 +""".strip() + +PLANNING_SCOPE_BASE = """ +职责边界: +1. 你是规划层,只负责“接下来怎么做”。 +2. 不重做分类判断,不解释系统内部实现。 +""".strip() + +GOVERNANCE_SCOPE_BASE = """ +职责边界: +1. 你是治理层,只负责边界约束、确认与安全守护。 +2. 不编造外部数据、数据库状态或执行结果。 +""".strip() + PROMPT_FEISHU_SIGNAL_EXTRACTION = """ 你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。 @@ -14,16 +34,22 @@ PROMPT_FEISHU_SIGNAL_EXTRACTION = """ "priority": "P0|P1|P2|P3", "matched_project_id": 1, "summary": "2-3句摘要", - "reasoning": ["依据1","依据2","依据3"], - "confidence": 0.0-1.0, + "reasoning": ["依据1", "依据2", "依据3"], + "confidence": 0.0, "recommended_action": "enter_only|create_charter|trigger_alert" }} 约束: 1. 无法确定 matched_project_id 时返回 null。 -2. reasoning 必须是 3 条简短 bullet。 -3. 只输出 JSON。 +2. reasoning 必须是 3 条简短依据。 +3. summary 聚焦业务信号,不输出执行承诺。 """ +PROMPT_FEISHU_SIGNAL_EXTRACTION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + EXTRACTION_SCOPE_BASE, + PROMPT_FEISHU_SIGNAL_EXTRACTION, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_FEISHU_PROJECT_PREFILL = """ 你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。 @@ -37,17 +63,22 @@ PROMPT_FEISHU_PROJECT_PREFILL = """ "suggested_description": "1-2句描述", "suggested_owner": "负责人名或角色;不明确时返回 null", "suggested_timeline": "截止日期或周期;不明确时返回 null", - "confidence": 0.0-1.0, - "reasoning": ["依据1","依据2","依据3"] + "confidence": 0.0, + "reasoning": ["依据1", "依据2", "依据3"] }} 约束: -1. reasoning 必须是 3 条简短 bullet。 -2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。 -3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。 -4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。 -5. 只输出 JSON。 +1. reasoning 必须是 3 条简短依据。 +2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。 +3. 不要为了填满字段而猜测;不明确时返回 null。 +4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。 """ +PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + EXTRACTION_SCOPE_BASE, + PROMPT_FEISHU_PROJECT_PREFILL, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_FEISHU_TASK_ORCHESTRATION = """ 你是 PMFlow 的任务编排与人员分配 AI。 @@ -67,28 +98,33 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """ "title": "任务标题(8-32字)", "description": "任务描述(40-200字)", "priority": "high|medium|low", - "estimated_hours": 1-80, + "estimated_hours": 1, "required_skill_names": ["FastAPI", "React"], "recommended_assignee_id": 123, "assignee_reason": "为什么推荐该成员", - "confidence": 0.0-1.0 + "confidence": 0.0 }} ] }} 约束: -1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。 -2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。 +1. 宪章是最高业务约束,优先围绕 objectives/scope/success_criteria/constraints 规划。 +2. charter_json 为空时,基于 project_json 与 signal_json 做最小可执行拆解,不要编造宪章。 3. 任务数量 2-8 条,不要空数组。 -4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 -5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 -6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 -7. 只输出 JSON,不要 Markdown,不要解释。 +4. 优先立即可执行任务,避免泛化管理话术。 +5. recommended_assignee_id 必须来自 members_json.user_id,无法确认时填 null。 +6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免继续压给高负载成员。 """ +PROMPT_FEISHU_TASK_ORCHESTRATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + PROMPT_FEISHU_TASK_ORCHESTRATION, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_SIGNAL_SOURCE_ONBOARDING = """ 首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 +你是 PMFlow 的信号源接入规划器。判断当前请求是否属于“新增/接入信号源”,并返回结构化计划。 输入: - request_json: {request_json} @@ -121,18 +157,21 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """ 规则: 1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 -2. 不编造 url / chat_id / repo / webhook。 -3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 -4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 -5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 -6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -7. 字段未知时返回空字符串、空数组或 null。 -8. 不要 Markdown,不要解释,只返回 JSON。 +2. 不编造 url/chat_id/repo/webhook。 +3. worker_roles 只允许 pm_dispatcher/tech_architect/delivery_coach。 +4. execution_instruction 保持简短,仅描述接入准备、任务拆解、负责人通知。 +5. 无法确定 source_identifier 时,返回短且稳定的键。 """ +PROMPT_SIGNAL_SOURCE_ONBOARDING = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + PROMPT_SIGNAL_SOURCE_ONBOARDING, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ 首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。 +你是 PMFlow 的下级 AI worker,正在执行一个 signal source onboarding 工作项。 输入: - role_json: {role_json} @@ -150,14 +189,17 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ }} 约束: -1. 输出必须体现当前 role_json 对应角色的职责边界。 -2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。 -3. 不要编造数据库 ID,不要输出代码块。 -4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。 -5. 字段未知时返回空字符串、空数组或 null。 -6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -7. 只输出 JSON,不要 Markdown,不要解释文字。 +1. 输出必须体现 role_json 对应角色职责边界。 +2. deliverables/risks/next_actions 各自最多 4 条。 +3. 顶层必须始终包含 summary/deliverables/risks/next_actions 四个键。 +4. 不要编造数据库 ID。 """ +PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 首字符必须是 {{,末字符必须是 }}。 @@ -169,7 +211,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ - session_project_id: {session_project_id} - world_model_project_id: {world_model_project_id} -只输出一个 JSON 对象(不要解释、不要 Markdown): +只输出一个 JSON 对象: {{ "family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup", "action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm", @@ -178,7 +220,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ "project_id": 0, "requested_count": 1, "requires_confirmation": false, - "parameters": { + "parameters": {{ "project_id": 0, "project_name": "可选", "task_id": 0, @@ -205,37 +247,36 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ "target_id": 0, "url": "可选", "path": "可选" - }, + }}, "reasoning": "一句话简述判定原因" }} 判定规则(按优先级): -1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。 -2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。 -3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。 -4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。 -5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 -6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。 -7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。 -8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。 -9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。 -10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 -11. signal.create few-shot: - - “给XX项目加个需求” -> signal.create - - “新增一个功能需求” -> signal.create - - “帮我记录这个想法” -> signal.create - - “提个需求:支持导出” -> signal.create -12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 -13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 -14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 -15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。 -16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。 -17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。 -18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 -19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 -20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。 -21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。 +1. query.read_only:仅当用户是查询/了解/分析已有内容时才返回。 +2. 出现新增/创建/记录/提交等写操作意图时,优先判定写操作意图。 +3. project.create:明确“创建新项目”。 +4. project.update/archive/reopen/delete:明确修改、归档、重开、删除项目。 +5. task.create:明确创建/新增/安排任务。 +6. task.update/assign/prioritize/status_transition/delete:明确修改任务相关动作。 +7. personnel.*:明确更新角色/工作量/可用性。 +8. knowledge.*:明确文档/分片/链接入库或重建索引。 +9. notification.*:明确创建、确认、关闭通知。 +10. signal.create:明确创建/录入新需求、功能、信号、想法或问题。 +11. project_signal discover:表达“先分析候选并确认”时,action=discover 且 requires_confirmation=true。 +12. signal_source.onboard:表达信号源/webhook/rss/github 接入时返回该意图。 +13. followup.continue 仅在 reply_to 命中待确认草稿时使用。 +14. 无 reply_to 草稿锚点时,不要因确认词误判 followup.continue。 +15. project_id 不确定返回 null;requested_count 默认 1(project_signal 可取 1-5)。 +16. family/action/intent 只能使用枚举值,不能自造。 +17. 写操作的 parameters 能识别则尽可能填写,不能识别时返回 null。 +18. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 包含 ddl_api_required。 """ +PROMPT_AI_PMO_INTENT_CLASSIFICATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + PROMPT_AI_PMO_INTENT_CLASSIFICATION, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ 首字符必须是 {{,末字符必须是 }}。 @@ -246,7 +287,7 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ - project_id: {project_id} - requested_count: {requested_count} -输出 JSON(只输出 JSON): +输出 JSON: {{ "signals": [ {{ @@ -262,15 +303,19 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ 规则: 1. signals 数量必须等于 requested_count。 -2. 标题和摘要禁止原样照抄整句用户指令。 -3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。 -4. 不要输出 Markdown,不要解释,不要代码块。 -""".strip() +2. 标题和摘要禁止原样照抄用户原句。 +3. 信息不足时可做合理拆分,但必须输出可执行、可入库文本。 +""" +PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + EXTRACTION_SCOPE_BASE, + PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_DRAFT_GENERATOR = """ 首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的资深产品经理。 -收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。 +你是 PMFlow 的资深产品经理。收到新业务需求后,请先做立项评审,再起草项目方案。 输入: - 当前上下文项目:{project_title} - {project_description} @@ -285,7 +330,7 @@ PROMPT_DRAFT_GENERATOR = """ 请输出 JSON,包含: - title: 语义化标题(10字以内,必须概括目标,不得复述原话) - summary: 项目定位与需求摘要(80字以内) -- similar_projects: 同名/相似项目列表,元素包含 project_id、title、difference、risk、reuse_suggestion +- similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion - mvp_items: MVP 建议数组,3-5 条 - prospect_assessment: 前景判断(1-3 句) - requirement_items: 需求清单数组,3-8 条 @@ -297,22 +342,23 @@ PROMPT_DRAFT_GENERATOR = """ - followup_plan: 数组,每项含 when 和 action 约束: -1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。 -2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。 -3. feasibility 判定规则: - - 明确的功能需求且技术可实现 => 合理 - - 需求模糊、边界冲突或依赖不清 => 待讨论 - - 明显不可行或超出系统范围 => 不合理 -4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。 -5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。 -6. 推荐负责人优先选择负载低于 80% 的成员。 -7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 -8. followup_plan 最少 1 项,最多 3 项。 -9. 若无法确定推荐人,recommended_assignee_id 返回 null。 -""".strip() +1. title 必须是语义化摘要,不能复述原始消息长片段。 +2. feasibility:明确可实现=合理;边界不清/依赖冲突=待讨论;明显超范围=不合理。 +3. 若存在同名或高度相似项目,必须写出差异与复用/合并建议。 +4. MVP 建议优先最小可验证闭环,不写泛化愿景。 +5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。 +6. followup_plan 最少 1 项,最多 3 项。 +7. 无法确定推荐人时,recommended_assignee_id 返回 null。 +""" +PROMPT_DRAFT_GENERATOR = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + PROMPT_DRAFT_GENERATOR, + JSON_OUTPUT_GUARDRAIL, +) PROMPT_RAG_INTENT_CLASSIFICATION = """ -你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 +你是 PMFlow 的意图分类器。请判断输入文本属于 question / statement / mixed。 输入: {message} @@ -324,9 +370,13 @@ PROMPT_RAG_INTENT_CLASSIFICATION = """ "question_part": "若无则空字符串", "statement_part": "若无则空字符串" }} - -要求:只输出 JSON。 """ +PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + EXTRACTION_SCOPE_BASE, + PROMPT_RAG_INTENT_CLASSIFICATION, + JSON_OUTPUT_GUARDRAIL, +) __all__ = [ "PROMPT_FEISHU_SIGNAL_EXTRACTION", diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index 3ea5c19f..a5adb2f0 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -1,64 +1,86 @@ from __future__ import annotations -PROMPT_RAG_ANSWER = """ -你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。 -要求: -1. 只基于给定上下文回答,不要编造。 -2. 若证据不足,明确说“不确定/信息不足”。 -3. 回答后用“来源:”列出引用的来源编号。 -4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。 -5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。 -6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。 -7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。 -8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。 -9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。 -10. 若字段缺失,显式列出缺失字段名,不要假设。 -11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。 -12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。 - 若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]" +from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt -当前项目ID:{project_id} -问题:{question} -检索最大相关度:{relevance_max} +RESPONSE_SCOPE_BASE = """ +职责边界: +1. 你是呈现层,只负责把已知事实转成可读答复。 +2. 不编造数据,不透露系统实现,不替用户做写操作。 +""".strip() -System Snapshot(全局): +PLANNING_SCOPE_BASE = """ +职责边界: +1. 你是规划层,只负责把上下文转成可执行方案。 +2. 不重复分类,不输出空泛管理话术。 +""".strip() + +GOVERNANCE_SCOPE_BASE = """ +职责边界: +1. 你是治理层,只负责评估、约束与风控建议。 +2. 不替代业务决策,不伪造证据,不输出泛化结论。 +""".strip() + +PROMPT_RAG_ANSWER = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + RESPONSE_SCOPE_BASE, + """ +你是 PMFlow 的 RAG 问答助手。 + +输入: +- 当前项目ID:{project_id} +- 问题:{question} +- 检索最大相关度:{relevance_max} +- System Snapshot: {system_snapshot} - -User Snapshot(当前用户): +- User Snapshot: {user_snapshot} - -最近会话(最多10轮): +- 最近会话: {conversation} - -检索上下文: +- 检索上下文: {context_blocks} -""" -PROMPT_DECISION_QA = """ -你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。 +回答规则: +1. 只基于给定上下文回答,不要编造。 +2. 若证据不足,明确说“不确定/信息不足”,并写出缺失项。 +3. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。 +4. 先给结论,再给关键依据,最后给下一步建议。 +5. 回答末尾用“来源:...”列出所依据的上下文片段名称或编号。 +6. 直接输出最终答案,不要输出思考过程。 +""", + TEXT_OUTPUT_GUARDRAIL, +) -规则: -1. 直接给出明确选择,不要回避,不要反问。 -2. 如果用户给出两个或多个选项,必须明确选一个。 -3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。 -4. 先给结论,再给 1-2 条理由,回答要简洁。 -5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。 +PROMPT_DECISION_QA = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ +你是 PMFlow 的产品决策顾问,负责处理产品取舍、优先级和方案比较类问题。 -问题:{question} -""" +输入: +- 问题:{question} -PROMPT_RAG_DB_PROJECT_QA = """ +回答规则: +1. 对取舍、优先级、方案比较,必须明确选择一个最稳妥的方向。 +2. 先给结论,再给 1-2 条理由,回答要简洁。 +3. 若选项没有写全,基于项目管理与办公协作常识给出最稳妥建议。 +4. 不要回避,不要反问,不要输出内部推理过程。 +""", + TEXT_OUTPUT_GUARDRAIL, +) + +PROMPT_RAG_DB_PROJECT_QA = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + RESPONSE_SCOPE_BASE, + """ 你是 PMFlow 的项目数据语义检索助手。 -你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。 +请基于“用户问题”和“项目索引(JSON)”做语义匹配后回答。 -规则: -1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。 -2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。 -3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。 -4. 若证据不足,请明确说明不足点,不要编造。 -5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。 +输入: +- 用户问题:{question} +- 项目索引(JSON): +{project_index} -输出 JSON(只输出 JSON,不要 Markdown): +输出 JSON: {{ "handled": true, "answer": "给用户展示的最终中文回答", @@ -66,65 +88,66 @@ PROMPT_RAG_DB_PROJECT_QA = """ "confidence": 0.0 }} -用户问题: -{question} +规则: +1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。 +2. answer 只写对用户有用的最终回答,不要复述索引原文。 +3. confidence 用 0.0-1.0 的小数。 +4. 不要输出 Markdown、解释文本或思考过程。 +""", + JSON_OUTPUT_GUARDRAIL, +) -项目索引(JSON): -{project_index} -""" - -PROMPT_GENERATE_CHARTER = """ -首字符必须是 {{,末字符必须是 }}。 +PROMPT_GENERATE_CHARTER = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ 你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。 输入: - 当前项目上下文:{project_context} - 现有活跃项目(JSON):{active_projects_json} -- 信号内容:{signal_content} +- 信号输入:{signal_content} -请先判断这条需求更适合作为: -1. 当前项目的延展; -2. 现有活跃项目的复用/合并; -3. 必须新建项目。 +请先判断是否存在同名或高度相似项目,再决定是否新建、复用、合并或暂缓。 -如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。 - -请输出 JSON 格式,包含以下字段: +请输出 JSON,包含: {{ "title": "需求宪章标题", "summary": "一句话摘要", "background": "背景与动机", "objectives": ["目标1", "目标2"], "scope": "范围说明", - "success_metrics": ["指标1", "指标2"], - "risks": ["风险1", "风险2"] + "success_criteria": ["标准1", "标准2"], + "risks": ["风险1", "风险2"], + "dependencies": ["依赖1", "依赖2"], + "mvp_items": ["MVP项1", "MVP项2"], + "owner_suggestion": "负责人建议", + "followup_questions": ["待确认问题1", "待确认问题2"] }} -只输出 JSON,不要其他文字。 -""" +约束: +1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。 +2. 若信息不足,followup_questions 最多 3 条。 +3. MVP 优先最小可验证闭环,不要写空泛愿景。 +4. 输出必须可直接被 json.loads 解析。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_DECOMPOSE_TASKS = """ +PROMPT_DECOMPOSE_TASKS = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ 你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。 -项目 ID:{project_id}。 -宪章内容: -{charter_content} +输入: +- 项目 ID:{project_id} +- 需求宪章(JSON):{charter_content} +- 可用技能:{available_skills} -请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。 +请识别每个任务所需的 AI 开发技能标签(仅从系统技能中匹配)。 -规则: -1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。 -2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。 -3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。 -4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。 -5. 每个任务必须生成稳定的 `task_key`,推荐格式为 `task-1`、`task-2`、`task-3`。 -6. `required_skill_names` 只写系统技能名称;`required_skill_tag_ids` 可留空,由后端按技能名称自动回填。 -7. `depends_on_task_keys` 用于描述任务依赖,必须引用前面任务的 `task_key`。 -8. `risk_level` 只能是 `low`、`medium`、`high` 或 `critical`。 -9. `confidence` 使用 0 到 1 的小数,越接近 1 表示越确定。 -10. `evidence_refs` 填写宪章或项目上下文里的证据位置,例如 `charter.objectives.0`、`charter.risks.1`。 - -输出 JSON 格式: +输出 JSON: {{ "tasks": [ {{ @@ -146,66 +169,89 @@ PROMPT_DECOMPOSE_TASKS = """ "detected_skills": ["检测到的所有技能"] }} -只输出 JSON,不要其他文字。 -""" +要求: +1. 任务要可执行、可验收,避免空泛管理语言。 +2. 优先体现与业务目标直接相关的最小闭环。 +3. evidence_refs 必须能对应到传入的 charter_content。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_RECOMMEND_ASSIGNEES = """ -你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。 +PROMPT_RECOMMEND_ASSIGNEES = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ +你是一位项目协调员。根据以下任务内容、所需技能和候选人信息,推荐 Top3 最合适的负责人。 -任务内容:{task_content} - -所需技能标签 IDs:{required_skill_tag_ids} - -候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count): +输入: +- 任务内容:{task_content} +- 所需技能标签 IDs:{required_skill_tag_ids} +- 候选人信息(JSON 数组): {candidates_json} -请考虑:当前负载、技能匹配度、历史表现。输出 JSON: +输出 JSON: {{ "recommendations": [ {{ "user_id": "用户ID", "user_name": "姓名", - "match_score": 0-100, + "match_score": 0, "reason": "推荐理由" }} ] }} -只输出 JSON。 -""" +规则: +1. 至多推荐 3 人,按 match_score 从高到低排序。 +2. 推荐理由要同时解释技能匹配与负载/可用性判断。 +3. 不要编造候选人列表中不存在的用户。 +4. match_score 使用 0-100 整数或小数都可,但需保持一致。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_ALIGNMENT_SCORE = """ +PROMPT_ALIGNMENT_SCORE = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 计算用户对真理节点的认知对齐度(0-100 分)。 -用户 ID:{user_id} -真理节点 ID:{truth_node_id} - -真理节点内容: +输入: +- 用户 ID:{user_id} +- 真理节点 ID:{truth_node_id} +- 真理节点内容: {truth_content} - -用户相关行为/产出(如有): +- 用户相关行为/产出: {user_context} -请评估该用户对该真理节点的理解与践行程度。输出 JSON: +输出 JSON: {{ - "alignment_score": 0-100, + "alignment_score": 0, "explanation": "简要说明" }} -只输出 JSON。 -""" +规则: +1. alignment_score 范围必须是 0-100。 +2. explanation 只写最关键的判断依据,不要写推理过程。 +3. 若证据不足,说明缺少什么。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_COGNITIVE_CONFLICTS = """ +PROMPT_COGNITIVE_CONFLICTS = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 识别真理节点下的隐性认知冲突。 -真理节点 ID:{truth_node_id} -真理节点内容: +输入: +- 真理节点 ID:{truth_node_id} +- 真理节点内容: {truth_content} - -相关用户的观点/产出: +- 用户观点/反馈: {user_viewpoints} -请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON: +输出 JSON: {{ "conflicts": [ {{ @@ -218,38 +264,54 @@ PROMPT_COGNITIVE_CONFLICTS = """ ] }} -只输出 JSON。 -""" +规则: +1. 只识别有明确证据支持的冲突。 +2. 没有冲突时返回空数组,不要编造。 +3. description 需要业务化表达,避免空泛术语。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_EVALUATE_MODEL_REQUIREMENT = """ +PROMPT_EVALUATE_MODEL_REQUIREMENT = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 评估需求与 AI 模型的匹配度。 -宪章内容:{charter_content} -模型版本 ID:{model_version_id} -模型信息(如有):{model_info} +输入: +- 需求内容:{charter_content} +- 模型版本 ID:{model_version_id} +- 模型说明:{model_info} -请评估兼容性、成本、风险。输出 JSON: +输出 JSON: {{ - "compatibility_score": 0-100, - "cost_estimate_usd": 预估日成本美元, - "cost_estimate_tokens_per_day": 预估日 token 量, + "compatibility_score": 0, + "cost_estimate_usd": 0.0, + "cost_estimate_tokens_per_day": 0, "risk_hints": ["风险1", "风险2"], "recommendations": ["建议1", "建议2"] }} -只输出 JSON。 -""" +规则: +1. compatibility_score 使用 0-100。 +2. 风险和建议必须能直接指导模型选型或范围调整。 +3. 若信息不足,明确指出无法判断的关键缺口。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_ANALYZE_CAPABILITY_GAP = """ +PROMPT_ANALYZE_CAPABILITY_GAP = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。 -需求宪章: -{charter_content} +输入: +- 需求描述:{requirement_description} +- 模型能力说明:{model_capabilities} +- 约束条件:{constraints} -模型信息: -{model_info} - -请识别并分类能力差距,输出 JSON: +输出 JSON: {{ "gaps": [ {{ @@ -261,39 +323,50 @@ PROMPT_ANALYZE_CAPABILITY_GAP = """ }} ], "overall_risk_level": "critical|high|medium|low", - "confidence": 0.0-1.0 + "confidence": 0.0 }} -只输出 JSON。 -""" +规则: +1. gaps 只列出真正影响落地的差距。 +2. overall_risk_level 要与 gaps 的严重程度保持一致。 +3. confidence 使用 0-1 小数。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_ANALYZE_COST_ROI = """ +PROMPT_ANALYZE_COST_ROI = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 分析 AI 项目的成本效益。 -总成本:{total_cost} -总 Token:{total_tokens} -任务数:{task_count} -项目内容:{truth_node_content} - -请计算: -1. ROI 评分 (0-10) -2. 成本效率评估 -3. 优化建议 +输入: +- 总成本:{total_cost} +- 总 token 数:{total_tokens} +- 任务数:{task_count} +- 项目内容: +{truth_node_content} 输出 JSON: {{ - "roi_score": 0.0-10.0, + "roi_score": 0.0, "cost_efficiency": "excellent|good|fair|poor", - "cost_per_task": 数值, + "cost_per_task": 0.0, "optimization_suggestions": ["建议1", "建议2"], "benchmark_comparison": "与行业标准比较" }} -只输出 JSON。 -""" +规则: +1. roi_score 使用 0.0-10.0。 +2. cost_per_task 必须可由输入推导,不能胡写。 +3. optimization_suggestions 要能实际指导降本或提效。 +""", + JSON_OUTPUT_GUARDRAIL, +) __all__ = [ "PROMPT_RAG_ANSWER", + "PROMPT_DECISION_QA", "PROMPT_RAG_DB_PROJECT_QA", "PROMPT_GENERATE_CHARTER", "PROMPT_DECOMPOSE_TASKS", diff --git a/backend/app/config/ai_prompts_catalog/part_04.py b/backend/app/config/ai_prompts_catalog/part_04.py index b89c62a4..37e6fce4 100644 --- a/backend/app/config/ai_prompts_catalog/part_04.py +++ b/backend/app/config/ai_prompts_catalog/part_04.py @@ -1,67 +1,118 @@ from __future__ import annotations -PROMPT_ANALYZE_COST_OPTIMIZATION = """ +from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +ANALYSIS_SCOPE_BASE = """ +职责边界: +1. 你是分析层,只负责基于输入做测算、比较与建议。 +2. 不编造缺失数据,不替代人工验证。 +""".strip() + +PLANNING_SCOPE_BASE = """ +职责边界: +1. 你是规划层,只负责给出可执行优化或重分配建议。 +2. 不重复分类判断,不解释系统内部实现。 +""".strip() + +GOVERNANCE_SCOPE_BASE = """ +职责边界: +1. 你是治理层,只负责变更评估、同步决策与风控建议。 +2. 不替代业务决策,不伪造证据,不输出泛化结论。 +""".strip() + +PROMPT_ANALYZE_COST_OPTIMIZATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 分析成本优化机会。 -成本历史:{cost_history} -项目内容:{truth_node_content} +输入: +- 成本历史:{cost_history} +- 项目内容: +{truth_node_content} 请识别优化机会,输出 JSON: {{ - "can_downgrade_model": true|false, - "downgrade_savings": 预计节省金额, + "can_downgrade_model": true, + "downgrade_savings": 0.0, "suggested_model": "建议的替代模型", - "high_cache_potential": true|false, - "cache_savings": 预计节省金额, - "batchable_operations": true|false, - "batch_savings": 预计节省金额, - "monthly_cost": 数值, - "total_potential_savings": 数值, - "roi_after_optimization": 数值 + "high_cache_potential": true, + "cache_savings": 0.0, + "batchable_operations": true, + "batch_savings": 0.0, + "monthly_cost": 0.0, + "total_potential_savings": 0.0, + "roi_after_optimization": 0.0 }} -只输出 JSON。 -""" +规则: +1. 只基于输入中的成本历史与项目内容做判断,不要编造。 +2. 结果要能直接指导降本动作。 +3. 不要输出解释文本,只输出 JSON。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """ +PROMPT_ASSESS_MODEL_UPDATE_IMPACT = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 评估模型升级影响。 -旧模型信息:{old_model_info} -新模型信息:{new_model_info} -受影响宪章:{affected_truth_nodes} +输入: +- 旧模型信息:{old_model_info} +- 新模型信息:{new_model_info} +- 受影响宪章:{affected_truth_nodes} 输出 JSON: {{ - "cost_change_percentage": 数值, + "cost_change_percentage": 0.0, "performance_change": {{ - "context_window_change_percent": 数值, - "quality_change_percent": 数值 + "context_window_change_percent": 0.0, + "quality_change_percent": 0.0 }}, "compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}], "recommendation": "immediate_upgrade|evaluate|defer" }} -只输出 JSON。 -""" +规则: +1. 结论要明确是否适合立即升级。 +2. 风险说明必须落到兼容性、稳定性或成本变化。 +3. 不要输出解释文本,只输出 JSON。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_EXPERIMENT_SYNC_DECISION = """ +PROMPT_EXPERIMENT_SYNC_DECISION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + GOVERNANCE_SCOPE_BASE, + """ 评估实验结果是否可自动同步到宪章。 -实验信息:{experiment_info} -宪章内容:{truth_content} -阈值:{threshold} +输入: +- 实验信息:{experiment_info} +- 宪章内容:{truth_content} +- 阈值:{threshold} 输出 JSON: {{ - "confidence_score": 0.0-1.0, - "auto_apply": true|false, + "confidence_score": 0.0, + "auto_apply": true, "reason": "原因" }} -只输出 JSON。 -""" +规则: +1. 只有实验结果与宪章目标高度一致时才允许 auto_apply=true。 +2. reason 要明确说明同步或拒绝同步的依据。 +3. 不要输出解释文本,只输出 JSON。 +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_SKILL_SCORE_UPDATE = """ +PROMPT_SKILL_SCORE_UPDATE = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ 你是 PMFlow 的技能评估引擎。 请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。 @@ -90,9 +141,14 @@ PROMPT_SKILL_SCORE_UPDATE = """ }} ] }} -""" +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_WORKLOAD_OPTIMIZATION = """ +PROMPT_WORKLOAD_OPTIMIZATION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ 你是 PMFlow 的任务分配优化器。 请基于团队成员技能与当前负载,输出任务重分配建议。 @@ -117,26 +173,29 @@ PROMPT_WORKLOAD_OPTIMIZATION = """ }} ] }} -""" +""", + JSON_OUTPUT_GUARDRAIL, +) -PROMPT_ANALYZE_FOOD_CALORIES = """ -你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。 +PROMPT_ANALYZE_FOOD_CALORIES = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + ANALYSIS_SCOPE_BASE, + """ +你是一位专业的营养分析师,擅长通过文本描述识别食物并估算热量和营养成分。 -请基于提供的食物描述或特征,识别食物类型并估算营养信息。 - -输入信息: +输入: {food_description} 请输出 JSON 格式,包含以下字段: {{ "food_name": "食物名称", - "confidence": 0.0-1.0, + "confidence": 0.0, "estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)", - "calories": 数值, - "protein": 数值, - "fat": 数值, - "carbs": 数值, - "fiber": 数值, + "calories": 0.0, + "protein": 0.0, + "fat": 0.0, + "carbs": 0.0, + "fiber": 0.0, "category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)", "cuisine": "菜系(如:中式、西式、日式、韩式等)", "description": "简短的食物描述", @@ -145,15 +204,19 @@ PROMPT_ANALYZE_FOOD_CALORIES = """ }} 估算规则: -1. 热量估算范围:考虑到份量不确定性,给出合理的中位值 -2. 营养成分:基于常见食物数据库估算 -3. confidence 根据描述清晰度和食物识别难度给出 -4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明 +1. 热量估算考虑份量不确定性,给出合理中位值。 +2. 营养成分基于常见食物数据库估算。 +3. confidence 根据描述清晰度和食物识别难度给出。 +4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明。 +5. 只输出 JSON,不要其他文字或 Markdown。 +""", + JSON_OUTPUT_GUARDRAIL, +) -只输出 JSON,不要其他文字或 Markdown。 -""" - -PROMPT_SIGNAL_PLANNER = """ +PROMPT_SIGNAL_PLANNER = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + PLANNING_SCOPE_BASE, + """ 你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。 输入: @@ -193,7 +256,9 @@ PROMPT_SIGNAL_PLANNER = """ 4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 5. confidence 范围 0-1,不能省略。 6. 只输出 JSON,不要输出 Markdown 或解释文本。 -""" +""", + JSON_OUTPUT_GUARDRAIL, +) __all__ = [ "PROMPT_ANALYZE_COST_OPTIMIZATION", diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py index 940d9d04..fe745cdc 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -1,21 +1,24 @@ from __future__ import annotations -FEISHU_QA_RESPONSE_STYLE = """ +from .part_01 import PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +FEISHU_QA_RESPONSE_STYLE = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + """ 回复风格要求: 1. 先给结论,再给关键依据,最后再给下一步。 2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 -""".strip() - -PROMPT_FEISHU_DIRECT_QA = "\n\n".join( - [ - """ -你是 PMFlow 的飞书项目管理顾问,负责直接问答。 """.strip(), - FEISHU_QA_RESPONSE_STYLE, - """ + TEXT_OUTPUT_GUARDRAIL, +) + +PROMPT_FEISHU_DIRECT_QA = _compose_prompt( + "你是 PMFlow 的飞书项目管理顾问,负责直接问答。", + FEISHU_QA_RESPONSE_STYLE, + """ 回答要求: 1. 仅基于给定上下文回答,优先使用结构化数据。 2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。 @@ -32,7 +35,6 @@ User Snapshot:{user_snapshot} 最近会话:{conversation} 检索上下文:{context_blocks} """.strip(), - ] ) __all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_03.py b/backend/app/config/ai_prompts_catalog/part_scenes_03.py index e8fdcc33..28ccc718 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_03.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_03.py @@ -1,7 +1,10 @@ from __future__ import annotations -PROMPT_PROJECT_STATUS_QA = """ -你是 PMFlow 的项目状态分析助手。 +from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +PROMPT_PROJECT_STATUS_QA = _compose_prompt( + "你是 PMFlow 的项目状态分析助手。", + """ 用户关注项目进度、状态、完成率、阶段和卡点。 规则: @@ -21,6 +24,8 @@ PROMPT_PROJECT_STATUS_QA = """ System Snapshot:{system_snapshot} User Snapshot:{user_snapshot} 检索上下文:{context_blocks} -""".strip() +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) __all__ = ["PROMPT_PROJECT_STATUS_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_04.py b/backend/app/config/ai_prompts_catalog/part_scenes_04.py index f7eede9d..fd0e8e35 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_04.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_04.py @@ -1,22 +1,27 @@ from __future__ import annotations -PROMPT_MEMBER_WORKLOAD_QA = """ -你是 PMFlow 的团队协作助手。 +from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +PROMPT_MEMBER_WORKLOAD_QA = _compose_prompt( + "你是 PMFlow 的团队协作助手。", + """ 用户询问团队成员或负载相关信息。 规则: -1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据 -2. 禁止默认返回项目列表 -3. 涉及负载用百分比表示 -4. 忽略与成员负载无关的项目列表和历史项目详情 -5. 推荐负责人时说明推荐理由 -6. 禁止输出思考过程 -7. 不超过150字 +1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据。 +2. 禁止默认返回项目列表。 +3. 涉及负载用百分比表示。 +4. 忽略与成员负载无关的项目列表和历史项目详情。 +5. 推荐负责人时说明推荐理由。 +6. 禁止输出思考过程。 +7. 不超过 150 字。 问题:{question} System Snapshot:{system_snapshot} User Snapshot:{user_snapshot} 检索上下文:{context_blocks} -""".strip() +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) __all__ = ["PROMPT_MEMBER_WORKLOAD_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_05.py b/backend/app/config/ai_prompts_catalog/part_scenes_05.py index f8bbfe41..a299b1f5 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_05.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_05.py @@ -1,14 +1,17 @@ from __future__ import annotations -PROMPT_AI_PMO_QA = """ -你是 PMFlow 的 AI 项目管理官。 +from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +PROMPT_AI_PMO_QA = _compose_prompt( + "你是 PMFlow 的 AI 项目管理官。", + """ 用户通过指令入口提问。 规则: -1. 你既是执行者也是回答者 -2. 问题涉及操作时先回答再提示可用指令 -3. 只基于给定上下文回答 -4. 禁止输出思考过程 +1. 你既是执行者也是回答者,但只回答当前上下文明确支持的内容。 +2. 问题涉及操作时先回答再提示可用指令。 +3. 只基于给定上下文回答,不要编造系统状态。 +4. 禁止输出思考过程。 5. 回答后附可操作建议: "你可以继续:[操作A]/[操作B]" @@ -16,6 +19,8 @@ PROMPT_AI_PMO_QA = """ 当前项目:{project_id} World Model:{world_model} 检索上下文:{context_blocks} -""".strip() +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) __all__ = ["PROMPT_AI_PMO_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index 108d6e68..76bcaeaa 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -1,29 +1,35 @@ from __future__ import annotations -PROMPT_EXTERNAL_TREND_QA = """ -你是 PMFlow 的市场情报助手。 +from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt + +PROMPT_EXTERNAL_TREND_QA = _compose_prompt( + "你是 PMFlow 的市场情报助手。", + """ 用户询问外部行业或市场信息。 规则: -1. 若有外部搜索结果优先使用 +1. 若有外部搜索结果优先使用。 2. 若无外部数据明确说: "当前未接入实时数据,以下基于通用行业认知。" 3. 结合项目实际给出应用建议: "对你们[项目名]的影响是..." -4. 禁止输出思考过程 +4. 禁止输出思考过程。 5. 分段输出: [行业动态] xxx [对项目的影响] xxx [建议] xxx -6. 控制总回复长度不超过800字,优先保留[建议]段 +6. 控制总回复长度不超过 800 字,优先保留[建议]段。 问题:{question} 项目上下文:{project_context} 外部搜索结果:{external_results} -""".strip() +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) -PROMPT_INDUSTRY_TREND_QA = """ -你是 PMFlow 的行业趋势助手。 +PROMPT_INDUSTRY_TREND_QA = _compose_prompt( + "你是 PMFlow 的行业趋势助手。", + """ 用户在询问某个行业、市场或产品方向的通用趋势判断。 规则: @@ -35,6 +41,8 @@ PROMPT_INDUSTRY_TREND_QA = """ 6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。 问题:{question} -""".strip() +""".strip(), + TEXT_OUTPUT_GUARDRAIL, +) __all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index dc876a0b..3ce356f3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -45,6 +45,7 @@ from .knowledge_base_doc import KnowledgeBaseDoc from .knowledge_base_chunk import KnowledgeBaseChunk from .qa_record import QARecord from .qa_feedback import QAFeedback +from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision from .qa_session import QASession from .qa_session_state import QASessionState from .kg_entity import KGEntity @@ -111,6 +112,8 @@ __all__ = [ "KnowledgeBaseChunk", "QARecord", "QAFeedback", + "FeedbackCenterRecord", + "FeedbackCenterRecordRevision", "QASession", "QASessionState", "KGEntity", diff --git a/backend/app/models/feedback_center.py b/backend/app/models/feedback_center.py new file mode 100644 index 00000000..3d282c6d --- /dev/null +++ b/backend/app/models/feedback_center.py @@ -0,0 +1,79 @@ +"""统一反馈中心模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .types import JSONB + + +class FeedbackCenterRecord(Base): + """统一反馈记录:承载任务、QA、信号三类学习样本。""" + + __tablename__ = "feedback_center_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + source_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + object_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + object_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + field_name: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + source_title: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True) + object_title: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True) + before_value: Mapped[Any] = mapped_column(JSONB, nullable=True) + after_value: Mapped[Any] = mapped_column(JSONB, nullable=True) + ai_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=True) + ai_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + reason_candidates: Mapped[Any] = mapped_column(JSONB, nullable=True) + reason_code: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + reason_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + review_status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="pending", index=True) + reviewed_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + reviewed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + auto_assigned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "source_type", + "source_id", + "object_type", + "object_id", + "field_name", + name="uq_feedback_center_records_source_object_field", + ), + ) + + revisions = relationship( + "FeedbackCenterRecordRevision", + back_populates="record", + cascade="all, delete-orphan", + ) + + +class FeedbackCenterRecordRevision(Base): + """统一反馈记录的修订历史。""" + + __tablename__ = "feedback_center_record_revisions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + record_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("feedback_center_records.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + before_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=False) + after_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=False) + reviewed_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + reviewed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + + record = relationship("FeedbackCenterRecord", back_populates="revisions") + + +__all__ = ["FeedbackCenterRecord", "FeedbackCenterRecordRevision"] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9ed5b048..a8399274 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -23,6 +23,21 @@ from .common import ( TaskItem, ) from .feedback import SignalFeedbackCreate, SignalFeedbackResponse +from .feedback_center import ( + FeedbackReasonCandidate, + FeedbackCenterRecordRevisionRead, + FeedbackCenterRecordRead, + FeedbackCenterSummaryReasonRead, + FeedbackCenterSummaryMetricsRead, + FeedbackCenterTrendDailyRead, + FeedbackCenterTrendWindowRead, + FeedbackCenterTrendDeltaRead, + FeedbackCenterTrendRead, + FeedbackCenterSummaryRead, + FeedbackCenterListRead, + FeedbackCenterReviewRequest, + FeedbackCenterReasonCatalogRead, +) from .model_capability import ( ValidateMatchRequest, LifecycleImpactRequest, @@ -79,6 +94,19 @@ __all__ = [ "TaskItem", "SignalFeedbackCreate", "SignalFeedbackResponse", + "FeedbackReasonCandidate", + "FeedbackCenterRecordRevisionRead", + "FeedbackCenterRecordRead", + "FeedbackCenterSummaryReasonRead", + "FeedbackCenterSummaryMetricsRead", + "FeedbackCenterTrendDailyRead", + "FeedbackCenterTrendWindowRead", + "FeedbackCenterTrendDeltaRead", + "FeedbackCenterTrendRead", + "FeedbackCenterSummaryRead", + "FeedbackCenterListRead", + "FeedbackCenterReviewRequest", + "FeedbackCenterReasonCatalogRead", "ValidateMatchRequest", "LifecycleImpactRequest", "GenerateMigrationTasksRequest", diff --git a/backend/app/schemas/feedback_center.py b/backend/app/schemas/feedback_center.py new file mode 100644 index 00000000..dbe6b644 --- /dev/null +++ b/backend/app/schemas/feedback_center.py @@ -0,0 +1,209 @@ +"""统一反馈中心 Pydantic 模型。""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class FeedbackReasonCandidate(BaseModel): + model_config = ConfigDict(extra="ignore") + + code: str + label: str + confidence: float = 0.0 + description: Optional[str] = None + source_type: Optional[str] = None + field_name: Optional[str] = None + + +class FeedbackCenterRecordRevisionRead(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") + + id: int + record_id: int + before_snapshot: Any + after_snapshot: Any + reviewed_by: Optional[int] = None + reviewed_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class FeedbackCenterRecordRead(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") + + id: int + tenant_id: int + project_id: Optional[int] = None + source_type: str + source_id: int + object_type: str + object_id: int + field_name: str + source_title: Optional[str] = None + object_title: Optional[str] = None + before_value: Any = None + after_value: Any = None + ai_snapshot: Any = None + ai_confidence: Optional[float] = None + reason_candidates: list[FeedbackReasonCandidate] = Field(default_factory=list) + reason_code: Optional[str] = None + reason_text: Optional[str] = None + review_status: str + reviewed_by: Optional[int] = None + reviewed_at: Optional[datetime] = None + auto_assigned: bool = False + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + revisions: list[FeedbackCenterRecordRevisionRead] = Field(default_factory=list) + + +class FeedbackCenterSummaryReasonRead(BaseModel): + code: str + label: str + count: int + + +class FeedbackCenterSummaryMetricsRead(BaseModel): + accepted_rate_reviewed: float + manual_override_rate: float + rework_rate: float + auto_assigned_rate: float + avg_review_latency_minutes: float + total_task_breakdown_count: int + total_task_override_count: int + total_qa_feedback_count: int + total_signal_feedback_count: int + total_revision_count: int + + +class FeedbackCenterTrendDailyRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + date: date + total_records: int + accepted_count: int + rejected_count: int + reviewed_count: int + pending_count: int + task_breakdown_count: int + task_override_count: int + qa_feedback_count: int + signal_feedback_count: int + auto_assigned_count: int + override_count: int + rework_count: int + accepted_rate_reviewed: float + manual_override_rate: float + rework_rate: float + auto_assigned_rate: float + avg_review_latency_minutes: float + revision_count: int + + +class FeedbackCenterTrendWindowRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + total_records: int + accepted_count: int + rejected_count: int + reviewed_count: int + pending_count: int + task_breakdown_count: int + task_override_count: int + qa_feedback_count: int + signal_feedback_count: int + auto_assigned_count: int + override_count: int + rework_count: int + accepted_rate_reviewed: float + manual_override_rate: float + rework_rate: float + auto_assigned_rate: float + avg_review_latency_minutes: float + revision_count: int + + +class FeedbackCenterTrendDeltaRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + total_records_delta: int + accepted_rate_reviewed_delta: float + manual_override_rate_delta: float + rework_rate_delta: float + auto_assigned_rate_delta: float + avg_review_latency_minutes_delta: float + + +class FeedbackCenterTrendRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + window_days: int + current_window_start: date + previous_window_start: date + daily: list[FeedbackCenterTrendDailyRead] + current: FeedbackCenterTrendWindowRead + previous: FeedbackCenterTrendWindowRead + deltas: FeedbackCenterTrendDeltaRead + + +class FeedbackCenterSummaryRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + tenant_id: int + project_id: Optional[int] = None + source_group: str = "all" + total: int + backlog_count: int + pending_count: int + reviewed_count: int + accepted_count: int + rejected_count: int + accepted_rate: float + avg_confidence: float + by_status: dict[str, int] + by_source_type: dict[str, int] + top_reasons: list[FeedbackCenterSummaryReasonRead] + metrics: FeedbackCenterSummaryMetricsRead + trend: FeedbackCenterTrendRead + + +class FeedbackCenterListRead(BaseModel): + model_config = ConfigDict(extra="ignore") + + items: list[FeedbackCenterRecordRead] + total: int + limit: int + offset: int + has_more: bool + + +class FeedbackCenterReviewRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + review_status: Literal["reviewed", "accepted", "rejected"] = "accepted" + reason_code: Optional[str] = None + reason_text: Optional[str] = None + + +class FeedbackCenterReasonCatalogRead(BaseModel): + items: list[FeedbackReasonCandidate] + + +__all__ = [ + "FeedbackReasonCandidate", + "FeedbackCenterRecordRevisionRead", + "FeedbackCenterRecordRead", + "FeedbackCenterSummaryReasonRead", + "FeedbackCenterSummaryMetricsRead", + "FeedbackCenterTrendDailyRead", + "FeedbackCenterTrendWindowRead", + "FeedbackCenterTrendDeltaRead", + "FeedbackCenterTrendRead", + "FeedbackCenterSummaryRead", + "FeedbackCenterListRead", + "FeedbackCenterReviewRequest", + "FeedbackCenterReasonCatalogRead", +] diff --git a/backend/app/services/feedback_center/__init__.py b/backend/app/services/feedback_center/__init__.py new file mode 100644 index 00000000..66e0bf94 --- /dev/null +++ b/backend/app/services/feedback_center/__init__.py @@ -0,0 +1,20 @@ +"""统一反馈中心服务。""" + +from .mappers import ( + map_qa_feedback_to_record_payload, + map_signal_feedback_to_record_payload, + map_task_breakdown_to_record_payload, + map_task_override_to_record_payloads, +) +from .reason_catalog import build_reason_candidates, list_reason_catalog +from .service import FeedbackCenterService + +__all__ = [ + "FeedbackCenterService", + "build_reason_candidates", + "list_reason_catalog", + "map_qa_feedback_to_record_payload", + "map_signal_feedback_to_record_payload", + "map_task_breakdown_to_record_payload", + "map_task_override_to_record_payloads", +] diff --git a/backend/app/services/feedback_center/mappers.py b/backend/app/services/feedback_center/mappers.py new file mode 100644 index 00000000..318321ed --- /dev/null +++ b/backend/app/services/feedback_center/mappers.py @@ -0,0 +1,296 @@ +"""统一反馈中心数据映射器。""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from .reason_catalog import build_reason_candidates + + +def _jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _jsonable(model_dump(mode="json")) + if hasattr(value, "__dict__"): + raw = {key: item for key, item in vars(value).items() if not key.startswith("_")} + if raw: + return _jsonable(raw) + return str(value) + + +def _safe_int(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _extract_task_breakdown_object(task: Any) -> dict[str, Any]: + payload = getattr(task, "ai_breakdown_payload", None) + if isinstance(payload, str): + try: + import json + + payload = json.loads(payload) + except Exception: + payload = None + if isinstance(payload, dict): + if isinstance(payload.get("task"), dict): + return _jsonable(payload["task"]) + tasks = payload.get("tasks") + if isinstance(tasks, list) and tasks: + current_title = str(getattr(task, "title", "") or "").strip().lower() + current_id = str(getattr(task, "id", "") or "").strip() + for item in tasks: + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").strip().lower() + task_key = str(item.get("task_key") or "").strip() + if (current_title and title == current_title) or (current_id and task_key == current_id): + return _jsonable(item) + first = tasks[0] + if isinstance(first, dict): + return _jsonable(first) + return { + "task_key": str(getattr(task, "id", "") or "").strip() or str(getattr(task, "title", "") or "").strip(), + "title": getattr(task, "title", None), + "description": getattr(task, "description", None), + "type": "task", + "estimated_hours": _jsonable(getattr(task, "estimated_hours", None)), + "required_skill_names": [], + "required_skill_tag_ids": list(getattr(task, "required_skill_tag_ids", None) or []), + "depends_on_task_keys": [], + "risk_level": "medium", + "risk_notes": None, + "confidence": 0.0, + "evidence_refs": [], + "priority": getattr(task, "priority", "medium"), + } + + +def map_task_breakdown_to_record_payload( + *, + task: Any, + project_title: str | None = None, + source_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + task_object = _extract_task_breakdown_object(task) + ai_snapshot = _jsonable(getattr(task, "ai_breakdown_payload", None)) + reason_candidates = build_reason_candidates( + source_type="task_breakdown", + field_name="task_object", + before_value=None, + after_value=task_object, + ) + return { + "tenant_id": int(getattr(task, "tenant_id", 1) or 1), + "project_id": int(getattr(task, "project_id", 0) or 0) or None, + "source_type": "task_breakdown", + "source_id": int(getattr(task, "id", 0) or 0), + "object_type": "task", + "object_id": int(getattr(task, "id", 0) or 0), + "field_name": "task_object", + "source_title": str(project_title or getattr(task, "title", "") or "")[:500] or None, + "object_title": str(getattr(task, "title", "") or "")[:500] or None, + "before_value": None, + "after_value": task_object, + "ai_snapshot": ai_snapshot, + "ai_confidence": float(task_object.get("confidence") or 0.0) if isinstance(task_object, dict) else None, + "reason_candidates": reason_candidates, + "reason_code": reason_candidates[0]["code"] if reason_candidates else None, + "reason_text": None, + "review_status": "pending", + "reviewed_by": None, + "reviewed_at": None, + "auto_assigned": bool(getattr(task, "assignee_id", None)), + } + + +def map_task_override_to_record_payloads(*, task: Any) -> list[dict[str, Any]]: + ai_object = _extract_task_breakdown_object(task) + raw_ai_payload = getattr(task, "ai_breakdown_payload", None) + if isinstance(raw_ai_payload, str): + try: + import json + + raw_ai_payload = json.loads(raw_ai_payload) + except Exception: + raw_ai_payload = None + raw_ai_map = raw_ai_payload if isinstance(raw_ai_payload, dict) else {} + overrides = getattr(task, "manual_overrides", None) or {} + if isinstance(overrides, str): + try: + import json + + overrides = json.loads(overrides) + except Exception: + overrides = {} + if not isinstance(overrides, dict): + overrides = dict(overrides or {}) + + payloads: list[dict[str, Any]] = [] + for field_name, after_value in overrides.items(): + if field_name == "manual_overrides": + continue + before_value = raw_ai_map.get(field_name) + if before_value is None: + before_value = ai_object.get(field_name) if isinstance(ai_object, dict) else getattr(task, field_name, None) + if _jsonable(before_value) == _jsonable(after_value): + continue + field_candidates = build_reason_candidates( + source_type="task_override", + field_name=str(field_name), + before_value=before_value, + after_value=after_value, + ) + payloads.append( + { + "tenant_id": int(getattr(task, "tenant_id", 1) or 1), + "project_id": int(getattr(task, "project_id", 0) or 0) or None, + "source_type": "task_override", + "source_id": int(getattr(task, "id", 0) or 0), + "object_type": "task", + "object_id": int(getattr(task, "id", 0) or 0), + "field_name": str(field_name), + "source_title": str(getattr(task, "title", "") or "")[:500] or None, + "object_title": str(getattr(task, "title", "") or "")[:500] or None, + "before_value": _jsonable(before_value), + "after_value": _jsonable(after_value), + "ai_snapshot": { + "ai_breakdown_payload": _jsonable(getattr(task, "ai_breakdown_payload", None)), + "manual_overrides": _jsonable(overrides), + }, + "ai_confidence": float(ai_object.get("confidence") or 0.0) if isinstance(ai_object, dict) else None, + "reason_candidates": field_candidates, + "reason_code": field_candidates[0]["code"] if field_candidates else None, + "reason_text": None, + "review_status": "accepted", + "reviewed_by": None, + "reviewed_at": None, + "auto_assigned": bool(getattr(task, "assignee_id", None)), + } + ) + return payloads + + +def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> dict[str, Any]: + rating = str(getattr(qa_feedback, "rating", "") or "").strip().lower() + helpfulness = getattr(qa_feedback, "helpfulness", None) + error_types = list(getattr(qa_feedback, "error_types", None) or []) + correct_answer = getattr(qa_feedback, "correct_answer", None) + before_value = { + "question": getattr(qa_record, "question", None), + "answer": getattr(qa_record, "answer", None), + "sources": _jsonable(getattr(qa_record, "sources", None) or []), + } + after_value = { + "rating": rating, + "helpfulness": helpfulness, + "error_types": [str(item).strip() for item in error_types if str(item).strip()], + "correct_answer": correct_answer, + } + reason_candidates = build_reason_candidates( + source_type="qa_feedback", + field_name="qa_feedback", + before_value=before_value, + after_value=after_value, + ) + return { + "tenant_id": int(getattr(qa_feedback, "tenant_id", getattr(qa_record, "tenant_id", 1)) or 1), + "project_id": int(getattr(qa_record, "project_id", 0) or 0) or None, + "source_type": "qa_feedback", + "source_id": int(getattr(qa_feedback, "id", 0) or 0), + "object_type": "qa_record", + "object_id": int(getattr(qa_feedback, "qa_id", 0) or 0), + "field_name": "qa_feedback", + "source_title": str(getattr(qa_record, "question", "") or "")[:500] or None, + "object_title": str(getattr(qa_record, "question", "") or "")[:500] or None, + "before_value": before_value, + "after_value": after_value, + "ai_snapshot": { + "question": getattr(qa_record, "question", None), + "answer": getattr(qa_record, "answer", None), + "sources": _jsonable(getattr(qa_record, "sources", None) or []), + "domains_used": _jsonable(getattr(qa_record, "domains_used", None) or []), + }, + "ai_confidence": None, + "reason_candidates": reason_candidates, + "reason_code": reason_candidates[0]["code"] if reason_candidates else None, + "reason_text": correct_answer or None, + "review_status": "accepted", + "reviewed_by": int(getattr(qa_feedback, "submitted_by", 0) or 0) or None, + "reviewed_at": None, + "auto_assigned": False, + } + + +def map_signal_feedback_to_record_payload(*, signal_feedback: Any, signal: Any) -> dict[str, Any]: + predicted = bool(getattr(signal_feedback, "ai_predicted_strategic", False)) + actual = bool(getattr(signal_feedback, "was_strategic", False)) + before_value = { + "ai_predicted_strategic": predicted, + "ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0), + } + after_value = { + "was_strategic": actual, + "reason": getattr(signal_feedback, "reason", None), + } + reason_candidates = build_reason_candidates( + source_type="signal_feedback", + field_name="strategic_classification", + before_value=before_value, + after_value=after_value, + ) + return { + "tenant_id": int(getattr(signal_feedback, "tenant_id", getattr(signal, "tenant_id", 1)) or 1), + "project_id": int(getattr(signal, "matched_project_id", 0) or 0) or None, + "source_type": "signal_feedback", + "source_id": int(getattr(signal_feedback, "id", 0) or 0), + "object_type": "signal", + "object_id": int(getattr(signal_feedback, "signal_id", 0) or 0), + "field_name": "strategic_classification", + "source_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None, + "object_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None, + "before_value": before_value, + "after_value": after_value, + "ai_snapshot": { + "signal_id": getattr(signal_feedback, "signal_id", None), + "title": getattr(signal, "title", None), + "content": getattr(signal, "content", None), + "impact_score": getattr(signal, "impact_score", None), + "priority": getattr(signal, "priority", None), + "strategic_analysis": _jsonable(getattr(signal, "strategic_analysis", None)), + }, + "ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0), + "reason_candidates": reason_candidates, + "reason_code": reason_candidates[0]["code"] if reason_candidates else None, + "reason_text": getattr(signal_feedback, "reason", None), + "review_status": "accepted", + "reviewed_by": _safe_int(getattr(signal_feedback, "user_id", None)), + "reviewed_at": None, + "auto_assigned": False, + } + + +__all__ = [ + "map_qa_feedback_to_record_payload", + "map_signal_feedback_to_record_payload", + "map_task_breakdown_to_record_payload", + "map_task_override_to_record_payloads", +] diff --git a/backend/app/services/feedback_center/reason_catalog.py b/backend/app/services/feedback_center/reason_catalog.py new file mode 100644 index 00000000..39e19d1f --- /dev/null +++ b/backend/app/services/feedback_center/reason_catalog.py @@ -0,0 +1,364 @@ +"""统一反馈中心的原因池与候选生成。""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Any + + +_REASON_DEFINITIONS: list[dict[str, Any]] = [ + { + "code": "owner_mismatch", + "label": "负责人不匹配", + "description": "AI 推荐、人工指派或协作责任边界需要复核。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"assignee_id"}, + "confidence": 0.96, + }, + { + "code": "capability_mismatch", + "label": "能力匹配偏差", + "description": "人员能力、技能覆盖或职责分配与任务要求不一致。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"assignee_id", "required_skill_tag_ids", "required_skill_names"}, + "confidence": 0.91, + }, + { + "code": "skill_gap", + "label": "技能要求不准", + "description": "任务技能标签、能力要求或技能覆盖范围需要修正。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"required_skill_tag_ids", "required_skill_names"}, + "confidence": 0.94, + }, + { + "code": "priority_misalignment", + "label": "优先级错配", + "description": "优先级建议与业务紧急度或协作节奏不匹配。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"priority"}, + "confidence": 0.89, + }, + { + "code": "dependency_wrong", + "label": "依赖关系错误", + "description": "任务之间的依赖、先后顺序或阻塞链路判断有误。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"depends_on_task_keys"}, + "confidence": 0.92, + }, + { + "code": "risk_underestimated", + "label": "风险低估", + "description": "任务风险等级、风险说明或执行风险识别偏低。", + "source_types": {"task_breakdown", "task_override", "signal_feedback"}, + "field_names": {"risk_level", "risk_notes", "strategic_classification"}, + "confidence": 0.9, + }, + { + "code": "effort_estimation_bias", + "label": "工时预估偏差", + "description": "任务预估工时与人工判断差距较大。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"estimated_hours"}, + "confidence": 0.95, + }, + { + "code": "scope_overbroad", + "label": "范围过大", + "description": "任务标题、描述或业务范围切分不够清晰。", + "source_types": {"task_breakdown", "task_override"}, + "field_names": {"title", "description"}, + "confidence": 0.82, + }, + { + "code": "incomplete_output", + "label": "输出信息不完整", + "description": "AI 输出缺少关键字段、上下文或业务约束。", + "source_types": {"task_breakdown", "task_override", "qa_feedback"}, + "field_names": {"task_object", "qa_feedback"}, + "confidence": 0.78, + }, + { + "code": "answer_incorrect", + "label": "答案不正确", + "description": "QA 回复存在事实错误、遗漏或推断偏差。", + "source_types": {"qa_feedback"}, + "field_names": {"qa_feedback"}, + "confidence": 0.93, + }, + { + "code": "source_issue", + "label": "来源问题", + "description": "QA 的来源引用、检索材料或证据组织存在问题。", + "source_types": {"qa_feedback"}, + "field_names": {"qa_feedback"}, + "confidence": 0.86, + }, + { + "code": "strategic_judgement_mismatch", + "label": "战略判断偏差", + "description": "AI 对信号是否战略相关的判断与人工判断不一致。", + "source_types": {"signal_feedback"}, + "field_names": {"strategic_classification"}, + "confidence": 0.97, + }, + { + "code": "signal_noise", + "label": "噪声信号", + "description": "信号本身噪声过高或不值得进入后续流程。", + "source_types": {"signal_feedback"}, + "field_names": {"strategic_classification"}, + "confidence": 0.8, + }, + { + "code": "manual_override", + "label": "人工覆盖", + "description": "字段由人工介入修改后形成反馈样本。", + "source_types": {"task_breakdown", "task_override", "qa_feedback", "signal_feedback"}, + "field_names": {"*", "task_object", "qa_feedback", "strategic_classification"}, + "confidence": 0.75, + }, + { + "code": "manual_review_needed", + "label": "需要人工复核", + "description": "当前样本仍需要人工确认,不足以自动通过。", + "source_types": {"task_breakdown", "task_override", "qa_feedback", "signal_feedback"}, + "field_names": {"*"}, + "confidence": 0.7, + }, +] + + +def _candidate_payload(definition: dict[str, Any], *, source_type: str, field_name: str) -> dict[str, Any]: + return { + "code": definition["code"], + "label": definition["label"], + "description": definition["description"], + "source_type": source_type, + "field_name": field_name, + "confidence": float(definition.get("confidence") or 0.0), + } + + +def list_reason_catalog( + *, + source_type: str | None = None, + field_name: str | None = None, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for definition in _REASON_DEFINITIONS: + if source_type and source_type not in definition["source_types"]: + continue + if field_name and "*" not in definition["field_names"] and field_name not in definition["field_names"]: + continue + items.append( + { + "code": definition["code"], + "label": definition["label"], + "description": definition["description"], + "source_types": sorted(definition["source_types"]), + "field_names": sorted(definition["field_names"]), + "confidence": float(definition.get("confidence") or 0.0), + } + ) + return items + + +def _append_unique(candidate_map: OrderedDict[str, dict[str, Any]], candidate: dict[str, Any]) -> None: + code = str(candidate.get("code") or "").strip() + if not code or code in candidate_map: + return + candidate_map[code] = candidate + + +def _normalize_text(value: Any) -> str: + return str(value or "").strip().lower() + + +def _is_truthy_change(before_value: Any, after_value: Any) -> bool: + if before_value is None and after_value is None: + return False + return before_value != after_value + + +def _estimated_hours_direction(before_value: Any, after_value: Any) -> str: + try: + before = float(before_value or 0) + after = float(after_value or 0) + except (TypeError, ValueError): + return "unknown" + if after > before: + return "increase" + if after < before: + return "decrease" + return "flat" + + +def build_reason_candidates( + *, + source_type: str, + field_name: str, + before_value: Any = None, + after_value: Any = None, +) -> list[dict[str, Any]]: + """基于字段差异构建稳定、可解释的候选原因。""" + + source_key = _normalize_text(source_type) + field_key = _normalize_text(field_name) + candidates: OrderedDict[str, dict[str, Any]] = OrderedDict() + + def add(code: str, label: str, confidence: float, description: str) -> None: + _append_unique( + candidates, + { + "code": code, + "label": label, + "description": description, + "source_type": source_key, + "field_name": field_key, + "confidence": float(confidence), + }, + ) + + if source_key == "task_override" or source_key == "task_breakdown": + if field_key in {"assignee_id", "owner_id"}: + add( + "owner_mismatch", + "负责人不匹配", + 0.96 if _is_truthy_change(before_value, after_value) else 0.88, + "负责人或责任归属与任务实际执行人不一致。", + ) + add( + "capability_mismatch", + "能力匹配偏差", + 0.91, + "人员能力、技能覆盖或职责分配与任务要求不一致。", + ) + if field_key in {"required_skill_tag_ids", "required_skill_names"}: + add( + "skill_gap", + "技能要求不准", + 0.94 if _is_truthy_change(before_value, after_value) else 0.82, + "任务所需技能标签需要复核。", + ) + add( + "capability_mismatch", + "能力匹配偏差", + 0.9, + "人员能力、技能覆盖或职责分配与任务要求不一致。", + ) + if field_key in {"depends_on_task_keys", "dependency", "dependencies"}: + add( + "dependency_wrong", + "依赖关系错误", + 0.92, + "任务依赖、前置关系或阻塞关系需要修正。", + ) + if field_key == "priority": + add( + "priority_misalignment", + "优先级错配", + 0.9, + "优先级建议与业务紧急度或协作节奏不匹配。", + ) + if field_key == "estimated_hours": + direction = _estimated_hours_direction(before_value, after_value) + confidence = 0.95 if direction in {"increase", "decrease"} else 0.8 + add( + "effort_estimation_bias", + "工时预估偏差", + confidence, + "AI 工时预估与人工判断存在明显差异。", + ) + if direction == "increase": + add( + "scope_overbroad", + "范围过大", + 0.78, + "任务范围可能需要进一步拆分。", + ) + if field_key in {"risk_level", "risk_notes"}: + add( + "risk_underestimated", + "风险低估", + 0.9, + "任务风险说明可能低估了实际执行复杂度。", + ) + if field_key in {"title", "description"}: + add( + "scope_overbroad", + "范围过大", + 0.84, + "任务标题或描述可能覆盖了过多内容。", + ) + if field_key == "task_object": + add( + "incomplete_output", + "输出信息不完整", + 0.76, + "模型输出仍可能缺失关键细节,需要人工补全。", + ) + add("manual_override", "人工覆盖", 0.78, "AI 拆解结果经过人工修改后形成学习样本。") + elif source_key == "qa_feedback": + rating = _normalize_text((after_value or {}).get("rating") if isinstance(after_value, dict) else None) + error_types = [] + if isinstance(after_value, dict): + error_types = [str(item).strip().lower() for item in after_value.get("error_types") or [] if str(item).strip()] + if rating == "negative": + if any(token in {"hallucination", "幻觉", "错误", "wrong"} for token in error_types): + add( + "answer_incorrect", + "答案不正确", + 0.95, + "反馈显示回答内容存在事实错误或幻觉。", + ) + add( + "source_issue", + "来源问题", + 0.84, + "回答所依赖的来源或证据组织需要复核。", + ) + add( + "incomplete_output", + "输出信息不完整", + 0.78, + "回答缺少关键信息或上下文不够完整。", + ) + else: + add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。") + elif source_key == "signal_feedback": + predicted = None + actual = None + if isinstance(before_value, dict): + predicted = before_value.get("ai_predicted_strategic") + if isinstance(after_value, dict): + actual = after_value.get("was_strategic") + if predicted is not None and actual is not None and bool(predicted) != bool(actual): + add( + "strategic_judgement_mismatch", + "战略性判断偏差", + 0.97, + "AI 对信号是否需要进入战略流程的判断与人工结果不一致。", + ) + add( + "signal_noise", + "噪声信号", + 0.82, + "当前信号可能缺少足够强的业务价值信号。", + ) + else: + add("manual_review_needed", "需要人工复核", 0.7, "当前样本需要进一步人工确认。") + + if not candidates: + add( + "manual_review_needed", + "需要人工复核", + 0.7, + "当前样本需要进一步人工确认。", + ) + + return list(candidates.values()) + + +__all__ = ["build_reason_candidates", "list_reason_catalog"] diff --git a/backend/app/services/feedback_center/service.py b/backend/app/services/feedback_center/service.py new file mode 100644 index 00000000..59c2cd3d --- /dev/null +++ b/backend/app/services/feedback_center/service.py @@ -0,0 +1,883 @@ +"""统一反馈中心业务服务。""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ...models import FeedbackCenterRecord, FeedbackCenterRecordRevision +from ...utils.timezone import now_cn_naive +from .mappers import ( + map_qa_feedback_to_record_payload, + map_signal_feedback_to_record_payload, + map_task_breakdown_to_record_payload, + map_task_override_to_record_payloads, +) +from .reason_catalog import build_reason_candidates, list_reason_catalog + +SOURCE_GROUP_VALUES = {"all", "task", "qa", "signal"} +SOURCE_GROUP_TASK_TYPES = ("task_breakdown", "task_override") +WINDOW_DAYS_VALUES = {7, 30} + + +def _normalize_jsonable(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _normalize_jsonable(item) for key, item in value.items()} + if isinstance(value, list): + return [_normalize_jsonable(item) for item in value] + if isinstance(value, tuple): + return [_normalize_jsonable(item) for item in value] + if hasattr(value, "model_dump"): + return _normalize_jsonable(value.model_dump(mode="json")) + return value + + +def _normalize_source_group(value: Any) -> str: + normalized = str(value or "all").strip().lower() + if normalized not in SOURCE_GROUP_VALUES: + raise ValueError(f"unsupported source_group: {normalized}") + return normalized + + +def _normalize_window_days(value: Any) -> int: + if value is None: + return 7 + normalized = int(value) + if normalized not in WINDOW_DAYS_VALUES: + raise ValueError(f"unsupported window_days: {normalized}") + return normalized + + +def _source_group_clause(source_group: str): + if source_group == "task": + return FeedbackCenterRecord.source_type.in_(SOURCE_GROUP_TASK_TYPES) + if source_group == "qa": + return FeedbackCenterRecord.source_type == "qa_feedback" + if source_group == "signal": + return FeedbackCenterRecord.source_type == "signal_feedback" + return None + + +def _apply_source_group(stmt, source_group: str): + clause = _source_group_clause(source_group) + if clause is not None: + stmt = stmt.where(clause) + return stmt + + +def _record_snapshot(record: FeedbackCenterRecord) -> dict[str, Any]: + # 避免异步会话下属性过期触发隐式 IO(MissingGreenlet)。 + created_at = record.__dict__.get("created_at") + updated_at = record.__dict__.get("updated_at") + return { + "id": int(record.id), + "tenant_id": int(record.tenant_id), + "project_id": int(record.project_id) if record.project_id is not None else None, + "source_type": record.source_type, + "source_id": int(record.source_id), + "object_type": record.object_type, + "object_id": int(record.object_id), + "field_name": record.field_name, + "source_title": record.source_title, + "object_title": record.object_title, + "before_value": _normalize_jsonable(record.before_value), + "after_value": _normalize_jsonable(record.after_value), + "ai_snapshot": _normalize_jsonable(record.ai_snapshot), + "ai_confidence": float(record.ai_confidence or 0.0) if record.ai_confidence is not None else None, + "reason_candidates": _normalize_jsonable(record.reason_candidates or []), + "reason_code": record.reason_code, + "reason_text": record.reason_text, + "review_status": record.review_status, + "reviewed_by": int(record.reviewed_by) if record.reviewed_by is not None else None, + "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, + "auto_assigned": bool(record.auto_assigned), + "created_at": created_at.isoformat() if created_at else None, + "updated_at": updated_at.isoformat() if updated_at else None, + } + + +def _revision_snapshot(revision: FeedbackCenterRecordRevision) -> dict[str, Any]: + return { + "id": int(revision.id), + "record_id": int(revision.record_id), + "before_snapshot": _normalize_jsonable(revision.before_snapshot), + "after_snapshot": _normalize_jsonable(revision.after_snapshot), + "reviewed_by": int(revision.reviewed_by) if revision.reviewed_by is not None else None, + "reviewed_at": revision.reviewed_at.isoformat() if revision.reviewed_at else None, + "created_at": revision.created_at.isoformat() if revision.created_at else None, + "updated_at": revision.updated_at.isoformat() if revision.updated_at else None, + } + + +def _trend_accumulator(date_value: date | None = None) -> dict[str, Any]: + return { + "date": date_value, + "total_records": 0, + "accepted_count": 0, + "rejected_count": 0, + "reviewed_count": 0, + "pending_count": 0, + "task_breakdown_count": 0, + "task_override_count": 0, + "qa_feedback_count": 0, + "signal_feedback_count": 0, + "auto_assigned_count": 0, + "override_count": 0, + "rework_count": 0, + "revision_count": 0, + "_breakdown_task_ids": set(), + "_override_task_ids": set(), + "_auto_assigned_task_ids": set(), + "_latencies": [], + "_reworked_record_ids": set(), + } + + +def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]: + breakdown_count = len(accumulator.pop("_breakdown_task_ids", set())) + override_count = len(accumulator.pop("_override_task_ids", set())) + auto_assigned_count = len(accumulator.pop("_auto_assigned_task_ids", set())) + rework_count = len(accumulator.pop("_reworked_record_ids", set())) + latencies = accumulator.pop("_latencies", []) + accepted_count = int(accumulator.get("accepted_count", 0)) + rejected_count = int(accumulator.get("rejected_count", 0)) + reviewed_count = int(accumulator.get("reviewed_count", 0)) + pending_count = int(accumulator.get("pending_count", 0)) + total_records = int(accumulator.get("total_records", 0)) + avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0 + reviewed_decisions = accepted_count + rejected_count + + accumulator["task_breakdown_count"] = breakdown_count + accumulator["task_override_count"] = override_count + accumulator["qa_feedback_count"] = int(accumulator.get("qa_feedback_count", 0)) + accumulator["signal_feedback_count"] = int(accumulator.get("signal_feedback_count", 0)) + accumulator["auto_assigned_count"] = auto_assigned_count + accumulator["override_count"] = override_count + accumulator["rework_count"] = rework_count + accumulator["revision_count"] = int(accumulator.get("revision_count", 0)) + accumulator["accepted_rate_reviewed"] = accepted_count / reviewed_decisions if reviewed_decisions else 0.0 + accumulator["manual_override_rate"] = override_count / breakdown_count if breakdown_count else 0.0 + accumulator["rework_rate"] = rework_count / total_records if total_records else 0.0 + accumulator["auto_assigned_rate"] = auto_assigned_count / breakdown_count if breakdown_count else 0.0 + accumulator["avg_review_latency_minutes"] = avg_review_latency_minutes + accumulator["reviewed_count"] = reviewed_count + accumulator["pending_count"] = pending_count + accumulator["accepted_count"] = accepted_count + accumulator["rejected_count"] = rejected_count + accumulator["total_records"] = total_records + if accumulator.get("date") is not None: + accumulator["date"] = accumulator["date"].isoformat() + return accumulator + + +class FeedbackCenterService: + """统一反馈中心服务。""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _base_stmt(*, tenant_id: int, project_id: Optional[int] = None): + stmt = select(FeedbackCenterRecord).where(FeedbackCenterRecord.tenant_id == int(tenant_id)) + if project_id is not None: + stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + return stmt + + @staticmethod + def _identity_filters(payload: dict[str, Any]): + return ( + FeedbackCenterRecord.tenant_id == int(payload["tenant_id"]), + FeedbackCenterRecord.source_type == str(payload["source_type"]), + FeedbackCenterRecord.source_id == int(payload["source_id"]), + FeedbackCenterRecord.object_type == str(payload["object_type"]), + FeedbackCenterRecord.object_id == int(payload["object_id"]), + FeedbackCenterRecord.field_name == str(payload["field_name"]), + ) + + async def _find_existing_record(self, payload: dict[str, Any]) -> FeedbackCenterRecord | None: + stmt = select(FeedbackCenterRecord).where(*self._identity_filters(payload)) + return (await self.db.execute(stmt)).scalar_one_or_none() + + def _merge_existing_record(self, record: FeedbackCenterRecord, payload: dict[str, Any]) -> bool: + mutable_fields = ( + "project_id", + "source_title", + "object_title", + "before_value", + "after_value", + "ai_snapshot", + "ai_confidence", + "reason_candidates", + "reason_code", + "reason_text", + "review_status", + "reviewed_by", + "reviewed_at", + "auto_assigned", + ) + changed = False + for field in mutable_fields: + next_value = payload.get(field) + current_value = getattr(record, field) + if _normalize_jsonable(current_value) == _normalize_jsonable(next_value): + continue + setattr(record, field, next_value) + changed = True + if changed: + record.updated_at = now_cn_naive() + return changed + + @staticmethod + def _apply_filters( + stmt, + *, + source_type: Optional[str] = None, + review_status: Optional[str] = None, + field_name: Optional[str] = None, + reason_code: Optional[str] = None, + search: Optional[str] = None, + ): + if source_type: + stmt = stmt.where(FeedbackCenterRecord.source_type == str(source_type)) + if review_status: + stmt = stmt.where(FeedbackCenterRecord.review_status == str(review_status)) + if field_name: + stmt = stmt.where(FeedbackCenterRecord.field_name == str(field_name)) + if reason_code: + stmt = stmt.where(FeedbackCenterRecord.reason_code == str(reason_code)) + if search: + like = f"%{str(search).strip()}%" + stmt = stmt.where( + or_( + func.coalesce(FeedbackCenterRecord.object_title, "").ilike(like), + func.coalesce(FeedbackCenterRecord.source_title, "").ilike(like), + ) + ) + return stmt + + @staticmethod + def _trend_add_record(accumulator: dict[str, Any], record: FeedbackCenterRecord, revision_count: int) -> None: + accumulator["total_records"] += 1 + status = str(record.review_status or "pending") + if status == "accepted": + accumulator["accepted_count"] += 1 + elif status == "rejected": + accumulator["rejected_count"] += 1 + elif status == "reviewed": + accumulator["reviewed_count"] += 1 + else: + accumulator["pending_count"] += 1 + + source_type = str(record.source_type or "") + object_type = str(record.object_type or "") + if source_type == "task_breakdown" and object_type == "task": + accumulator["_breakdown_task_ids"].add(int(record.object_id)) + if bool(record.auto_assigned): + accumulator["_auto_assigned_task_ids"].add(int(record.object_id)) + if source_type == "task_override" and object_type == "task": + accumulator["_override_task_ids"].add(int(record.object_id)) + if source_type == "qa_feedback": + accumulator["qa_feedback_count"] += 1 + if source_type == "signal_feedback": + accumulator["signal_feedback_count"] += 1 + + if revision_count > 0: + accumulator["revision_count"] += int(revision_count) + if revision_count > 1: + accumulator["_reworked_record_ids"].add(int(record.id)) + + if record.created_at and record.reviewed_at: + delta = (record.reviewed_at - record.created_at).total_seconds() / 60 + if delta >= 0: + accumulator["_latencies"].append(delta) + + async def _build_trend( + self, + *, + tenant_id: int, + project_id: Optional[int] = None, + window_days: int = 7, + source_group: str = "all", + ) -> dict[str, Any]: + window_days = _normalize_window_days(window_days) + source_group = _normalize_source_group(source_group) + today = now_cn_naive().date() + current_start = today - timedelta(days=window_days - 1) + previous_start = current_start - timedelta(days=window_days) + end_exclusive = today + timedelta(days=1) + start_dt = datetime.combine(previous_start, time.min) + end_dt = datetime.combine(end_exclusive, time.min) + + stmt = ( + select(FeedbackCenterRecord) + .options(selectinload(FeedbackCenterRecord.revisions)) + .where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.created_at.is_not(None), + FeedbackCenterRecord.created_at >= start_dt, + FeedbackCenterRecord.created_at < end_dt, + ) + ) + ) + if project_id is not None: + stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + stmt = _apply_source_group(stmt, source_group) + records = (await self.db.execute(stmt)).scalars().all() + + current_daily: dict[date, dict[str, Any]] = { + current_start + timedelta(days=index): _trend_accumulator(current_start + timedelta(days=index)) + for index in range(window_days) + } + current_window = _trend_accumulator() + previous_window = _trend_accumulator() + + for record in records: + created_at = getattr(record, "created_at", None) + if created_at is None: + continue + created_date = created_at.date() + revision_count = len(record.revisions or []) + if current_start <= created_date <= today: + bucket = current_daily.get(created_date) + if bucket is not None: + self._trend_add_record(bucket, record, revision_count) + self._trend_add_record(current_window, record, revision_count) + continue + if previous_start <= created_date < current_start: + self._trend_add_record(previous_window, record, revision_count) + + daily = [_finalize_trend_accumulator(current_daily[current_start + timedelta(days=index)]) for index in range(window_days)] + current = _finalize_trend_accumulator(current_window) + previous = _finalize_trend_accumulator(previous_window) + + deltas = { + "total_records_delta": int(current["total_records"] - previous["total_records"]), + "accepted_rate_reviewed_delta": float(current["accepted_rate_reviewed"] - previous["accepted_rate_reviewed"]), + "manual_override_rate_delta": float(current["manual_override_rate"] - previous["manual_override_rate"]), + "rework_rate_delta": float(current["rework_rate"] - previous["rework_rate"]), + "auto_assigned_rate_delta": float(current["auto_assigned_rate"] - previous["auto_assigned_rate"]), + "avg_review_latency_minutes_delta": float( + current["avg_review_latency_minutes"] - previous["avg_review_latency_minutes"] + ), + } + + return { + "window_days": window_days, + "current_window_start": current_start, + "previous_window_start": previous_start, + "daily": daily, + "current": current, + "previous": previous, + "deltas": deltas, + } + + async def summary( + self, + *, + tenant_id: int, + project_id: Optional[int] = None, + window_days: int = 7, + source_group: str = "all", + ) -> dict[str, Any]: + window_days = _normalize_window_days(window_days) + source_group = _normalize_source_group(source_group) + stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id) + stmt = _apply_source_group(stmt, source_group) + total_stmt = select(func.count()).select_from(stmt.subquery()) + total = int((await self.db.execute(total_stmt)).scalar_one() or 0) + + status_stmt = select(FeedbackCenterRecord.review_status, func.count()).group_by( + FeedbackCenterRecord.review_status + ) + status_stmt = status_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) + if project_id is not None: + status_stmt = status_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + status_stmt = _apply_source_group(status_stmt, source_group) + status_rows = await self.db.execute(status_stmt) + by_status = {str(status or "pending"): int(count or 0) for status, count in status_rows.all()} + + source_stmt = select(FeedbackCenterRecord.source_type, func.count()).group_by(FeedbackCenterRecord.source_type) + source_stmt = source_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) + if project_id is not None: + source_stmt = source_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + source_stmt = _apply_source_group(source_stmt, source_group) + source_rows = await self.db.execute(source_stmt) + by_source_type = {str(source_type or "unknown"): int(count or 0) for source_type, count in source_rows.all()} + + confidence_stmt = select(func.avg(FeedbackCenterRecord.ai_confidence)) + confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) + if project_id is not None: + confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + confidence_stmt = _apply_source_group(confidence_stmt, source_group) + avg_confidence = float((await self.db.execute(confidence_stmt)).scalar_one() or 0.0) + + trend = await self._build_trend( + tenant_id=tenant_id, + project_id=project_id, + window_days=window_days, + source_group=source_group, + ) + + accepted_count = int(by_status.get("accepted", 0)) + reviewed_count = int(by_status.get("reviewed", 0)) + pending_count = int(by_status.get("pending", 0)) + rejected_count = int(by_status.get("rejected", 0)) + backlog_count = pending_count + reviewed_count + accepted_rate = (accepted_count / total) if total else 0.0 + + reason_stmt = select(FeedbackCenterRecord.reason_code, func.count()) + reason_stmt = reason_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id)) + if project_id is not None: + reason_stmt = reason_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + reason_stmt = _apply_source_group(reason_stmt, source_group) + reason_stmt = reason_stmt.where(FeedbackCenterRecord.reason_code.is_not(None)) + reason_stmt = reason_stmt.group_by(FeedbackCenterRecord.reason_code).order_by(func.count().desc()) + reason_rows = (await self.db.execute(reason_stmt)).all() + top_reasons: list[dict[str, Any]] = [] + for reason_code, count in reason_rows[:5]: + if not reason_code: + continue + catalog = list_reason_catalog() + label = next((item["label"] for item in catalog if item["code"] == reason_code), reason_code) + top_reasons.append( + { + "code": str(reason_code), + "label": label, + "count": int(count or 0), + } + ) + + async def _distinct_task_count(*, source_type: str, auto_assigned: Optional[bool] = None) -> int: + stmt = select(func.count(func.distinct(FeedbackCenterRecord.object_id))).where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.source_type == source_type, + FeedbackCenterRecord.object_type == "task", + ) + ) + if project_id is not None: + stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + stmt = _apply_source_group(stmt, source_group) + if auto_assigned is not None: + stmt = stmt.where(FeedbackCenterRecord.auto_assigned.is_(auto_assigned)) + return int((await self.db.execute(stmt)).scalar_one() or 0) + + task_breakdown_count = int(by_source_type.get("task_breakdown", 0)) + task_override_count = int(by_source_type.get("task_override", 0)) + qa_feedback_count = int(by_source_type.get("qa_feedback", 0)) + signal_feedback_count = int(by_source_type.get("signal_feedback", 0)) + + breakdown_task_count = await _distinct_task_count(source_type="task_breakdown") + override_task_count = await _distinct_task_count(source_type="task_override") + auto_assigned_task_count = await _distinct_task_count(source_type="task_breakdown", auto_assigned=True) + + accepted_count_reviewed = accepted_count + reviewed_decision_total = accepted_count + rejected_count + accepted_rate_reviewed = accepted_count_reviewed / reviewed_decision_total if reviewed_decision_total else 0.0 + + manual_override_rate = override_task_count / breakdown_task_count if breakdown_task_count else 0.0 + auto_assigned_rate = auto_assigned_task_count / breakdown_task_count if breakdown_task_count else 0.0 + + revision_scope = ( + select(FeedbackCenterRecordRevision) + .join( + FeedbackCenterRecord, + FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id, + ) + .where( + and_( + FeedbackCenterRecordRevision.tenant_id == int(tenant_id), + FeedbackCenterRecord.tenant_id == int(tenant_id), + ) + ) + ) + if project_id is not None: + revision_scope = revision_scope.where(FeedbackCenterRecord.project_id == int(project_id)) + revision_scope = _apply_source_group(revision_scope, source_group) + revision_count_stmt = select(func.count()).select_from(revision_scope.subquery()) + revision_count = int((await self.db.execute(revision_count_stmt)).scalar_one() or 0) + + rework_stmt = ( + select(FeedbackCenterRecordRevision.record_id, func.count().label("cnt")) + .join( + FeedbackCenterRecord, + FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id, + ) + .where( + and_( + FeedbackCenterRecordRevision.tenant_id == int(tenant_id), + FeedbackCenterRecord.tenant_id == int(tenant_id), + ) + ) + .group_by(FeedbackCenterRecordRevision.record_id) + .having(func.count() > 1) + ) + if project_id is not None: + rework_stmt = rework_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + rework_stmt = _apply_source_group(rework_stmt, source_group) + rework_count_stmt = select(func.count()).select_from(rework_stmt.subquery()) + rework_record_count = int((await self.db.execute(rework_count_stmt)).scalar_one() or 0) + rework_rate = rework_record_count / total if total else 0.0 + + latency_stmt = select(FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.reviewed_at.is_not(None), + ) + ) + if project_id is not None: + latency_stmt = latency_stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + latency_stmt = _apply_source_group(latency_stmt, source_group) + latency_rows = (await self.db.execute(latency_stmt)).all() + latencies: list[float] = [] + for created_at, reviewed_at in latency_rows: + if not created_at or not reviewed_at: + continue + delta = (reviewed_at - created_at).total_seconds() / 60 + if delta >= 0: + latencies.append(delta) + avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0 + + return { + "tenant_id": int(tenant_id), + "project_id": int(project_id) if project_id is not None else None, + "source_group": source_group, + "total": total, + "backlog_count": backlog_count, + "pending_count": pending_count, + "reviewed_count": reviewed_count, + "accepted_count": accepted_count, + "rejected_count": rejected_count, + "accepted_rate": accepted_rate, + "avg_confidence": avg_confidence, + "by_status": by_status, + "by_source_type": by_source_type, + "top_reasons": top_reasons, + "metrics": { + "accepted_rate_reviewed": accepted_rate_reviewed, + "manual_override_rate": manual_override_rate, + "rework_rate": rework_rate, + "auto_assigned_rate": auto_assigned_rate, + "avg_review_latency_minutes": avg_review_latency_minutes, + "total_task_breakdown_count": task_breakdown_count, + "total_task_override_count": task_override_count, + "total_qa_feedback_count": qa_feedback_count, + "total_signal_feedback_count": signal_feedback_count, + "total_revision_count": revision_count, + }, + "trend": trend, + } + + async def list_records( + self, + *, + tenant_id: int, + project_id: Optional[int] = None, + source_type: Optional[str] = None, + review_status: Optional[str] = None, + field_name: Optional[str] = None, + reason_code: Optional[str] = None, + search: Optional[str] = None, + limit: int = 20, + offset: int = 0, + ) -> dict[str, Any]: + limit = max(1, min(int(limit), 100)) + offset = max(0, int(offset)) + stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id) + stmt = self._apply_filters( + stmt, + source_type=source_type, + review_status=review_status, + field_name=field_name, + reason_code=reason_code, + search=search, + ) + total_stmt = select(func.count()).select_from(stmt.subquery()) + total = int((await self.db.execute(total_stmt)).scalar_one() or 0) + + stmt = stmt.options(selectinload(FeedbackCenterRecord.revisions)) + stmt = stmt.order_by(FeedbackCenterRecord.created_at.desc().nullslast(), FeedbackCenterRecord.id.desc()) + rows = (await self.db.execute(stmt.offset(offset).limit(limit))).scalars().all() + items = [self._serialize_record(row, include_revisions=False) for row in rows] + return { + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "has_more": offset + len(items) < total, + } + + async def get_detail( + self, + *, + tenant_id: int, + record_id: int, + project_id: Optional[int] = None, + ) -> dict[str, Any] | None: + stmt = ( + select(FeedbackCenterRecord) + .options(selectinload(FeedbackCenterRecord.revisions)) + .where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.id == int(record_id), + ) + ) + ) + if project_id is not None: + stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id)) + record = (await self.db.execute(stmt)).scalar_one_or_none() + if not record: + return None + return { + "record": self._serialize_record(record, include_revisions=True), + "revisions": [self._serialize_revision(revision) for revision in record.revisions or []], + } + + async def review( + self, + *, + tenant_id: int, + record_id: int, + reviewer_id: int, + review_status: str, + reason_code: str | None = None, + reason_text: str | None = None, + reason_candidates: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + stmt = ( + select(FeedbackCenterRecord) + .options(selectinload(FeedbackCenterRecord.revisions)) + .where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.id == int(record_id), + ) + ) + ) + record = (await self.db.execute(stmt)).scalar_one_or_none() + if not record: + raise ValueError("feedback record not found") + + previous_snapshot = self._serialize_record(record, include_revisions=False) + record.review_status = str(review_status) + record.reason_code = str(reason_code).strip()[:64] if reason_code else record.reason_code + record.reason_text = str(reason_text).strip()[:4000] if reason_text else record.reason_text + if reason_candidates is not None: + record.reason_candidates = reason_candidates + elif not record.reason_candidates: + record.reason_candidates = build_reason_candidates( + source_type=record.source_type, + field_name=record.field_name, + before_value=record.before_value, + after_value=record.after_value, + ) + record.reviewed_by = int(reviewer_id) + record.reviewed_at = now_cn_naive() + record.updated_at = record.reviewed_at + await self.db.flush() + + current_snapshot = self._serialize_record(record, include_revisions=False) + self.db.add( + FeedbackCenterRecordRevision( + tenant_id=int(tenant_id), + record_id=int(record.id), + before_snapshot=previous_snapshot, + after_snapshot=current_snapshot, + reviewed_by=int(reviewer_id), + reviewed_at=record.reviewed_at, + ) + ) + await self.db.commit() + await self.db.refresh(record) + refreshed = await self.get_detail(tenant_id=tenant_id, record_id=record.id) + if refreshed is None: + raise ValueError("feedback record not found after review") + return refreshed["record"] + + async def ingest_payloads( + self, + *, + payloads: list[dict[str, Any]], + commit: bool = False, + ) -> list[FeedbackCenterRecord]: + records: list[FeedbackCenterRecord] = [] + for payload in payloads: + normalized = self._normalize_payload(payload) + existing = await self._find_existing_record(normalized) + if existing is None: + record = FeedbackCenterRecord(**normalized) + self.db.add(record) + records.append(record) + continue + + previous_snapshot = self._serialize_record(existing, include_revisions=False) + changed = self._merge_existing_record(existing, normalized) + if changed: + current_snapshot = self._serialize_record(existing, include_revisions=False) + self.db.add( + FeedbackCenterRecordRevision( + tenant_id=int(existing.tenant_id), + record_id=int(existing.id), + before_snapshot=previous_snapshot, + after_snapshot=current_snapshot, + reviewed_by=normalized.get("reviewed_by"), + reviewed_at=normalized.get("reviewed_at") or now_cn_naive(), + ) + ) + records.append(existing) + if commit: + await self.db.commit() + for record in records: + await self.db.refresh(record) + else: + await self.db.flush() + return records + + async def ingest_task_breakdown( + self, + *, + task: Any, + project_title: str | None = None, + source_context: dict[str, Any] | None = None, + commit: bool = False, + ) -> FeedbackCenterRecord | None: + payload = map_task_breakdown_to_record_payload( + task=task, + project_title=project_title, + source_context=source_context, + ) + records = await self.ingest_payloads(payloads=[payload], commit=commit) + return records[0] if records else None + + async def ingest_task_overrides( + self, + *, + task: Any, + commit: bool = False, + ) -> list[FeedbackCenterRecord]: + payloads = map_task_override_to_record_payloads(task=task) + if not payloads: + return [] + return await self.ingest_payloads(payloads=payloads, commit=commit) + + async def ingest_qa_feedback( + self, + *, + qa_feedback: Any, + qa_record: Any, + commit: bool = False, + ) -> FeedbackCenterRecord | None: + payload = map_qa_feedback_to_record_payload(qa_feedback=qa_feedback, qa_record=qa_record) + records = await self.ingest_payloads(payloads=[payload], commit=commit) + return records[0] if records else None + + async def ingest_signal_feedback( + self, + *, + signal_feedback: Any, + signal: Any, + commit: bool = False, + ) -> FeedbackCenterRecord | None: + payload = map_signal_feedback_to_record_payload(signal_feedback=signal_feedback, signal=signal) + records = await self.ingest_payloads(payloads=[payload], commit=commit) + return records[0] if records else None + + async def get_signal_feedback_stats(self, *, tenant_id: int) -> dict[str, Any]: + stmt = select(FeedbackCenterRecord).where( + and_( + FeedbackCenterRecord.tenant_id == int(tenant_id), + FeedbackCenterRecord.source_type == "signal_feedback", + ) + ) + rows = (await self.db.execute(stmt)).scalars().all() + total = len(rows) + if total == 0: + return { + "total_feedbacks": 0, + "accuracy": 0.0, + "true_positives": 0, + "false_positives": 0, + "true_negatives": 0, + "false_negatives": 0, + } + + def _predicted_actual(row: FeedbackCenterRecord) -> tuple[bool, bool]: + before = row.before_value if isinstance(row.before_value, dict) else {} + after = row.after_value if isinstance(row.after_value, dict) else {} + predicted = bool(before.get("ai_predicted_strategic") if isinstance(before, dict) else False) + actual = bool(after.get("was_strategic") if isinstance(after, dict) else False) + return predicted, actual + + correct = 0 + tp = fp = tn = fn = 0 + for row in rows: + predicted, actual = _predicted_actual(row) + if predicted == actual: + correct += 1 + if predicted and actual: + tp += 1 + elif predicted and not actual: + fp += 1 + elif not predicted and not actual: + tn += 1 + elif not predicted and actual: + fn += 1 + return { + "total_feedbacks": total, + "accuracy": correct / total if total else 0.0, + "true_positives": tp, + "false_positives": fp, + "true_negatives": tn, + "false_negatives": fn, + } + + def _serialize_record(self, record: FeedbackCenterRecord, *, include_revisions: bool) -> dict[str, Any]: + payload = _record_snapshot(record) + if include_revisions: + payload["revisions"] = [self._serialize_revision(revision) for revision in getattr(record, "revisions", [])] + else: + payload["revisions"] = [] + return payload + + def _serialize_revision(self, revision: FeedbackCenterRecordRevision) -> dict[str, Any]: + return _revision_snapshot(revision) + + def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + normalized["tenant_id"] = int(normalized.get("tenant_id") or 1) + normalized["project_id"] = int(normalized["project_id"]) if normalized.get("project_id") not in {None, ""} else None + normalized["source_id"] = int(normalized.get("source_id") or 0) + normalized["object_id"] = int(normalized.get("object_id") or 0) + normalized["source_type"] = str(normalized.get("source_type") or "").strip()[:32] + normalized["object_type"] = str(normalized.get("object_type") or "").strip()[:32] + normalized["field_name"] = str(normalized.get("field_name") or "").strip()[:128] + normalized["source_title"] = str(normalized.get("source_title") or "").strip()[:500] or None + normalized["object_title"] = str(normalized.get("object_title") or "").strip()[:500] or None + normalized["before_value"] = _normalize_jsonable(normalized.get("before_value")) + normalized["after_value"] = _normalize_jsonable(normalized.get("after_value")) + normalized["ai_snapshot"] = _normalize_jsonable(normalized.get("ai_snapshot")) + ai_confidence = normalized.get("ai_confidence") + normalized["ai_confidence"] = float(ai_confidence) if ai_confidence not in {None, ""} else None + reason_candidates = normalized.get("reason_candidates") + normalized["reason_candidates"] = _normalize_jsonable(reason_candidates or []) + normalized["reason_code"] = str(normalized.get("reason_code") or "").strip()[:64] or None + normalized["reason_text"] = str(normalized.get("reason_text") or "").strip()[:4000] or None + normalized["review_status"] = str(normalized.get("review_status") or "pending").strip()[:16] + reviewed_by = normalized.get("reviewed_by") + normalized["reviewed_by"] = int(reviewed_by) if reviewed_by not in {None, ""} else None + normalized["reviewed_at"] = normalized.get("reviewed_at") or None + normalized["auto_assigned"] = bool(normalized.get("auto_assigned", False)) + normalized.pop("provider", None) + normalized.pop("model_name", None) + normalized.pop("prompt_version", None) + normalized.pop("schema_version", None) + return normalized + + +__all__ = ["FeedbackCenterService"] diff --git a/backend/requirements.txt b/backend/requirements.txt index d797a21c..7afbde01 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,9 @@ fastapi>=0.115.0 uvicorn[standard]>=0.30.0 sqlalchemy>=2.0.30 alembic>=1.13.2 +greenlet>=3.0.0 asyncpg>=0.29.0 +aiosqlite>=0.20.0 psycopg[binary]>=3.2.0 pgvector>=0.3.5 python-dotenv>=1.0.1 diff --git a/backend/tests/test_ai_prompt_registry_contract.py b/backend/tests/test_ai_prompt_registry_contract.py index e143c7ef..436efa3f 100644 --- a/backend/tests/test_ai_prompt_registry_contract.py +++ b/backend/tests/test_ai_prompt_registry_contract.py @@ -1,9 +1,16 @@ from app.config.ai_prompt_registry import ( PROMPT_REGISTRY_MAP, + PROMPT_FAMILY_ALLOWED, get_prompt_registry, get_prompt_registry_entry, iter_prompt_registry_keys, ) +from app.config.ai_prompts import ( + PROMPT_DECOMPOSE_TASKS, + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, + PROMPT_FEISHU_REQUIREMENT_ANALYSIS, + PROMPT_TASK_BREAKDOWN, +) from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS @@ -18,6 +25,10 @@ def test_prompt_registry_covers_all_catalog_exports() -> None: entry = get_prompt_registry_entry(key) assert entry is not None assert entry["prompt_key"] == key + assert entry["prompt_family"] in PROMPT_FAMILY_ALLOWED + assert entry["responsibility_scope"] + assert entry["priority"] in {"high", "medium", "low"} + assert entry["audience"] def test_prompt_registry_returns_detached_copies() -> None: @@ -30,18 +41,22 @@ def test_prompt_registry_returns_detached_copies() -> None: def test_high_frequency_registry_entries_have_complete_metadata() -> None: - required_keys = ( - "PROMPT_SIGNAL_PLANNER", - "PROMPT_GENERATE_CHARTER", - "PROMPT_DECOMPOSE_TASKS", - "PROMPT_PROJECT_STATUS_QA", - "PROMPT_MEMBER_WORKLOAD_QA", - "PROMPT_AI_PMO_QA", - "PROMPT_EXTERNAL_TREND_QA", - "PROMPT_INDUSTRY_TREND_QA", - ) + expected_families = { + "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION": "classification", + "PROMPT_FEISHU_LEVEL_CLASSIFICATION": "classification", + "PROMPT_FEISHU_SIGNAL_EXTRACTION": "extraction", + "PROMPT_FEISHU_TASK_ORCHESTRATION": "planning", + "PROMPT_SIGNAL_PLANNER": "planning", + "PROMPT_GENERATE_CHARTER": "planning", + "PROMPT_DECOMPOSE_TASKS": "planning", + "PROMPT_PROJECT_STATUS_QA": "response", + "PROMPT_MEMBER_WORKLOAD_QA": "response", + "PROMPT_AI_PMO_QA": "response", + "PROMPT_EXTERNAL_TREND_QA": "response", + "PROMPT_INDUSTRY_TREND_QA": "response", + } - for prompt_key in required_keys: + for prompt_key, family in expected_families.items(): entry = get_prompt_registry_entry(prompt_key) assert entry is not None assert entry["module"] @@ -50,9 +65,21 @@ def test_high_frequency_registry_entries_have_complete_metadata() -> None: assert entry["output_contract"] assert isinstance(entry["call_sites"], tuple) assert entry["call_sites"], prompt_key + assert entry["prompt_family"] == family + assert entry["responsibility_scope"] signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER") assert signal_planner_entry is not None assert "charter_json" in signal_planner_entry["input_contract"] assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"]) assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"]) + assert "规划" in signal_planner_entry["responsibility_scope"] + + +def test_deprecated_prompt_aliases_point_to_canonical_prompts() -> None: + registry_keys = set(iter_prompt_registry_keys()) + + assert "PROMPT_FEISHU_REQUIREMENT_ANALYSIS" not in registry_keys + assert "PROMPT_TASK_BREAKDOWN" not in registry_keys + assert PROMPT_FEISHU_REQUIREMENT_ANALYSIS == PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION + assert PROMPT_TASK_BREAKDOWN == PROMPT_DECOMPOSE_TASKS diff --git a/backend/tests/test_feedback_center_api.py b/backend/tests/test_feedback_center_api.py new file mode 100644 index 00000000..2efedb0a --- /dev/null +++ b/backend/tests/test_feedback_center_api.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import os +from datetime import datetime +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 + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test" + +from app.api.ai_steward_api import routes_feedback_center +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.feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision +import app.services.feedback_center.service as feedback_center_service_module + + +def _use_sqlite_json_types() -> None: + for table in (FeedbackCenterRecord.__table__, FeedbackCenterRecordRevision.__table__): + for column in table.columns: + if column.name in {"before_value", "after_value", "ai_snapshot", "reason_candidates", "before_snapshot", "after_snapshot"}: + column.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=[ + FeedbackCenterRecord.__table__, + FeedbackCenterRecordRevision.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_app(db_session): + app = FastAPI() + app.include_router(routes_feedback_center.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_feedback_center_summary_list_detail_and_catalog(db_session) -> None: + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=101, + object_type="task", + object_id=201, + field_name="task_object", + source_title="任务拆解", + object_title="任务拆解", + before_value=None, + after_value={"title": "任务拆解"}, + ai_confidence=0.91, + reason_candidates=[{"code": "manual_override", "label": "人工覆盖"}], + reason_code="manual_override", + review_status="pending", + auto_assigned=False, + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=102, + object_type="qa_record", + object_id=202, + field_name="qa_feedback", + source_title="Q&A", + object_title="Q&A", + before_value={"answer": "旧答案"}, + after_value={"rating": "negative", "correct_answer": "新答案"}, + ai_confidence=None, + reason_candidates=[{"code": "answer_incorrect", "label": "答案不正确"}], + reason_code="answer_incorrect", + review_status="reviewed", + auto_assigned=False, + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=22, + source_type="signal_feedback", + source_id=103, + object_type="signal", + object_id=203, + field_name="strategic_classification", + source_title="上线异常告警", + object_title="上线异常告警", + before_value={"ai_predicted_strategic": True}, + after_value={"was_strategic": False}, + ai_confidence=0.8, + reason_candidates=[{"code": "strategic_judgement_mismatch", "label": "战略判断偏差"}], + reason_code="strategic_judgement_mismatch", + review_status="accepted", + auto_assigned=False, + ), + ] + ) + await db_session.commit() + + app = _build_app(db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + summary_resp = await client.get("/ai-steward/feedback-center/summary", params={"project_id": 11}) + list_resp = await client.get("/ai-steward/feedback-center/items", params={"project_id": 11, "limit": 20}) + detail_resp = await client.get("/ai-steward/feedback-center/items/1", params={"project_id": 11}) + catalog_resp = await client.get( + "/ai-steward/feedback-center/reason-catalog", + params={"source_type": "task_override", "field_name": "priority"}, + ) + + assert summary_resp.status_code == 200 + summary_payload = summary_resp.json() + assert summary_payload["code"] == 0 + assert summary_payload["data"]["total"] == 2 + assert summary_payload["data"]["by_status"]["pending"] == 1 + assert summary_payload["data"]["by_status"]["reviewed"] == 1 + + assert list_resp.status_code == 200 + list_payload = list_resp.json() + assert list_payload["code"] == 0 + assert len(list_payload["data"]["items"]) == 2 + assert all(item["project_id"] == 11 for item in list_payload["data"]["items"]) + + assert detail_resp.status_code == 200 + detail_payload = detail_resp.json() + assert detail_payload["code"] == 0 + assert detail_payload["data"]["record"]["id"] == 1 + assert detail_payload["data"]["record"]["source_type"] == "task_breakdown" + assert detail_payload["data"]["revisions"] == [] + + assert catalog_resp.status_code == 200 + catalog_payload = catalog_resp.json() + assert catalog_payload["code"] == 0 + codes = [item["code"] for item in catalog_payload["data"]["items"]] + assert "priority_misalignment" in codes + + +@pytest.mark.asyncio +async def test_feedback_center_summary_honors_window_and_scope_query_params(db_session, monkeypatch) -> None: + fixed_now = datetime(2025, 1, 15, 12, 0, 0) + monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now) + + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=801, + object_type="task", + object_id=901, + field_name="task_breakdown", + source_title="任务拆解", + object_title="任务拆解", + review_status="accepted", + auto_assigned=True, + created_at=datetime(2025, 1, 15, 8, 0, 0), + reviewed_at=datetime(2025, 1, 15, 8, 30, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=802, + object_type="task", + object_id=901, + field_name="priority", + source_title="优先级修正", + object_title="优先级修正", + review_status="reviewed", + created_at=datetime(2025, 1, 14, 8, 0, 0), + reviewed_at=datetime(2025, 1, 14, 8, 50, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=803, + object_type="qa_record", + object_id=902, + field_name="qa_feedback", + source_title="QA 反馈", + object_title="QA 反馈", + review_status="accepted", + created_at=datetime(2025, 1, 13, 8, 0, 0), + reviewed_at=datetime(2025, 1, 13, 8, 20, 0), + ), + ] + ) + await db_session.commit() + + app = _build_app(db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/ai-steward/feedback-center/summary", + params={"project_id": 11, "window_days": 30, "source_group": "task"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert payload["data"]["source_group"] == "task" + assert payload["data"]["trend"]["window_days"] == 30 + assert payload["data"]["trend"]["current"]["total_records"] == 2 + assert payload["data"]["trend"]["current"]["task_breakdown_count"] == 1 + assert payload["data"]["trend"]["current"]["task_override_count"] == 1 + assert payload["data"]["trend"]["current"]["qa_feedback_count"] == 0 + + +@pytest.mark.asyncio +async def test_feedback_center_summary_accepts_scope_params(db_session) -> None: + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=201, + object_type="task", + object_id=301, + field_name="task_object", + review_status="pending", + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=202, + object_type="qa_record", + object_id=302, + field_name="qa_feedback", + review_status="accepted", + ), + ] + ) + await db_session.commit() + + app = _build_app(db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + scoped_resp = await client.get( + "/ai-steward/feedback-center/summary", + params={"project_id": 11, "window_days": 30, "source_group": "qa"}, + ) + invalid_window = await client.get( + "/ai-steward/feedback-center/summary", + params={"project_id": 11, "window_days": 14}, + ) + invalid_group = await client.get( + "/ai-steward/feedback-center/summary", + params={"project_id": 11, "source_group": "unknown"}, + ) + + assert scoped_resp.status_code == 200 + scoped_payload = scoped_resp.json() + assert scoped_payload["code"] == 0 + assert scoped_payload["data"]["trend"]["window_days"] == 30 + assert scoped_payload["data"]["by_source_type"] == {"qa_feedback": 1} + + assert invalid_window.status_code == 400 + assert invalid_group.status_code == 400 + + +@pytest.mark.asyncio +async def test_feedback_center_review_updates_record_and_revision(db_session) -> None: + db_session.add( + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=500, + object_type="task", + object_id=600, + field_name="priority", + source_title="优先级修正", + object_title="优先级修正", + before_value="P2", + after_value="P1", + ai_confidence=0.62, + reason_candidates=[{"code": "priority_misalignment", "label": "优先级错配"}], + reason_code="priority_misalignment", + review_status="pending", + auto_assigned=False, + ) + ) + await db_session.commit() + + app = _build_app(db_session) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/ai-steward/feedback-center/items/1/review", + json={ + "review_status": "accepted", + "reason_code": "priority_misalignment", + "reason_text": "优先级确认需要上调", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert payload["data"]["review_status"] == "accepted" + assert payload["data"]["reason_code"] == "priority_misalignment" + assert payload["data"]["reviewed_by"] == 9001 + + refreshed = await db_session.get(FeedbackCenterRecord, 1) + assert refreshed is not None + assert refreshed.review_status == "accepted" + assert refreshed.reason_text == "优先级确认需要上调" + + revisions = ( + await db_session.execute( + FeedbackCenterRecordRevision.__table__.select().where(FeedbackCenterRecordRevision.record_id == 1) + ) + ).mappings().all() + assert len(revisions) == 1 + assert revisions[0]["before_snapshot"]["review_status"] == "pending" + assert revisions[0]["after_snapshot"]["review_status"] == "accepted" diff --git a/backend/tests/test_feedback_center_service.py b/backend/tests/test_feedback_center_service.py new file mode 100644 index 00000000..ee0bacf3 --- /dev/null +++ b/backend/tests/test_feedback_center_service.py @@ -0,0 +1,787 @@ +from __future__ import annotations + +import os +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" + +from app.models.base import Base +from app.models.feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision +from app.services.feedback_center.mappers import ( + map_qa_feedback_to_record_payload, + map_signal_feedback_to_record_payload, + map_task_override_to_record_payloads, +) +from app.services.feedback_center.reason_catalog import build_reason_candidates +import app.services.feedback_center.service as feedback_center_service_module +from app.services.feedback_center.service import FeedbackCenterService + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + FeedbackCenterRecord.__table__, + FeedbackCenterRecordRevision.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_summary_list_and_detail_respect_tenant_and_project_scope(db_session) -> None: + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=101, + object_type="qa_record", + object_id=1, + field_name="qa_feedback", + before_value={"answer": "A"}, + after_value={"rating": "negative"}, + review_status="pending", + reason_code="answer_incorrect", + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="signal_feedback", + source_id=102, + object_type="signal", + object_id=2, + field_name="strategic_classification", + before_value={"predicted": True}, + after_value={"actual": False}, + review_status="reviewed", + reason_code="answer_incorrect", + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=22, + source_type="task_override", + source_id=103, + object_type="task", + object_id=3, + field_name="estimated_hours", + before_value=8, + after_value=13, + review_status="accepted", + reason_code="effort_estimation_bias", + ), + FeedbackCenterRecord( + tenant_id=2, + project_id=11, + source_type="qa_feedback", + source_id=201, + object_type="qa_record", + object_id=9, + field_name="qa_feedback", + before_value={"answer": "B"}, + after_value={"rating": "negative"}, + review_status="pending", + reason_code="answer_incorrect", + ), + ] + ) + await db_session.commit() + + service = FeedbackCenterService(db_session) + summary = await service.summary(tenant_id=1, project_id=11) + records = await service.list_records(tenant_id=1, project_id=11, limit=50, offset=0) + + assert summary["total"] == 2 + assert summary["by_status"]["pending"] == 1 + assert summary["by_status"]["reviewed"] == 1 + assert summary["by_source_type"]["qa_feedback"] == 1 + assert summary["by_source_type"]["signal_feedback"] == 1 + top_reason_code = summary["top_reasons"][0].get("reason_code") or summary["top_reasons"][0].get("code") + assert top_reason_code == "answer_incorrect" + assert summary["top_reasons"][0]["count"] == 2 + assert records["total"] == 2 + assert len(records["items"]) == 2 + assert all(item["tenant_id"] == 1 for item in records["items"]) + assert all(item["project_id"] == 11 for item in records["items"]) + + detail = await service.get_detail( + tenant_id=1, + project_id=11, + record_id=records["items"][0]["id"], + ) + assert detail is not None + assert detail["record"]["project_id"] == 11 + assert detail["revisions"] == [] + + +@pytest.mark.asyncio +async def test_review_updates_record_and_appends_revision_in_single_flow(db_session) -> None: + record = FeedbackCenterRecord( + tenant_id=1, + project_id=31, + source_type="task_override", + source_id=501, + object_type="task", + object_id=88, + field_name="priority", + before_value="P2", + after_value="P0", + reason_code="manual_override", + reason_text="初始记录", + review_status="pending", + ) + db_session.add(record) + await db_session.commit() + + service = FeedbackCenterService(db_session) + reviewed = await service.review( + tenant_id=1, + record_id=record.id, + reviewer_id=9001, + review_status="accepted", + reason_code="priority_misalignment", + reason_text="已确认优先级应提升", + reason_candidates=[{"code": "priority_misalignment", "label": "优先级偏差"}], + ) + + assert reviewed["id"] == record.id + assert reviewed["review_status"] == "accepted" + assert reviewed["reason_code"] == "priority_misalignment" + assert reviewed["reviewed_by"] == 9001 + assert reviewed["reviewed_at"] is not None + + revisions = ( + await db_session.execute( + select(FeedbackCenterRecordRevision).where( + FeedbackCenterRecordRevision.tenant_id == 1, + FeedbackCenterRecordRevision.record_id == record.id, + ) + ) + ).scalars().all() + assert len(revisions) == 1 + assert revisions[0].before_snapshot["review_status"] == "pending" + assert revisions[0].after_snapshot["review_status"] == "accepted" + assert revisions[0].reviewed_by == 9001 + + +@pytest.mark.asyncio +async def test_ingest_payloads_upsert_same_identity_and_append_revision(db_session) -> None: + service = FeedbackCenterService(db_session) + initial = await service.ingest_payloads( + payloads=[ + { + "tenant_id": 1, + "project_id": 44, + "source_type": "task_override", + "source_id": 808, + "object_type": "task", + "object_id": 909, + "field_name": "priority", + "source_title": "优先级调整", + "object_title": "优先级调整", + "before_value": "P2", + "after_value": "P1", + "ai_snapshot": {"priority": "P2"}, + "ai_confidence": 0.61, + "reason_candidates": [{"code": "priority_misalignment", "label": "优先级错配"}], + "reason_code": "priority_misalignment", + "review_status": "pending", + "reviewed_by": None, + "reviewed_at": None, + "auto_assigned": False, + } + ], + commit=True, + ) + assert len(initial) == 1 + record_id = initial[0].id + + updated = await service.ingest_payloads( + payloads=[ + { + "tenant_id": 1, + "project_id": 44, + "source_type": "task_override", + "source_id": 808, + "object_type": "task", + "object_id": 909, + "field_name": "priority", + "source_title": "优先级调整(人工确认)", + "object_title": "优先级调整(人工确认)", + "before_value": "P2", + "after_value": "P0", + "ai_snapshot": {"priority": "P2"}, + "ai_confidence": 0.72, + "reason_candidates": [{"code": "priority_misalignment", "label": "优先级错配"}], + "reason_code": "priority_misalignment", + "review_status": "accepted", + "reviewed_by": 42, + "reviewed_at": None, + "auto_assigned": False, + } + ], + commit=True, + ) + + assert len(updated) == 1 + assert updated[0].id == record_id + + detail = await service.get_detail(tenant_id=1, record_id=record_id) + assert detail is not None + assert detail["record"]["after_value"] == "P0" + assert detail["record"]["review_status"] == "accepted" + assert len(detail["revisions"]) == 1 + assert detail["revisions"][0]["before_snapshot"]["after_value"] == "P1" + assert detail["revisions"][0]["after_snapshot"]["after_value"] == "P0" + + +def test_reason_catalog_is_deterministic_for_field_diff() -> None: + first = build_reason_candidates( + source_type="task_override", + field_name="estimated_hours", + before_value=5, + after_value=13, + ) + second = build_reason_candidates( + source_type="task_override", + field_name="estimated_hours", + before_value=5, + after_value=13, + ) + + assert first == second + assert first[0]["code"] == "effort_estimation_bias" + + +def test_source_mappers_generate_unified_payloads() -> None: + qa_payload = map_qa_feedback_to_record_payload( + qa_feedback=SimpleNamespace( + id=1, + tenant_id=1, + qa_id=7, + rating="negative", + helpfulness="low", + error_types=["hallucination"], + correct_answer="正确答案", + submitted_by=100, + created_at=None, + ), + qa_record=SimpleNamespace( + project_id=99, + question="问题", + answer="旧答案", + ), + ) + assert qa_payload["source_type"] == "qa_feedback" + assert qa_payload["project_id"] == 99 + assert qa_payload["object_id"] == 7 + + signal_payload = map_signal_feedback_to_record_payload( + signal_feedback=SimpleNamespace( + id=2, + tenant_id=1, + signal_id=33, + was_strategic=False, + ai_predicted_strategic=True, + ai_confidence=0.81, + reason="噪音信号", + user_id="u-1", + created_at=None, + ), + signal=SimpleNamespace( + matched_project_id=55, + title="上线异常告警", + ), + ) + assert signal_payload["source_type"] == "signal_feedback" + assert signal_payload["project_id"] == 55 + assert signal_payload["field_name"] == "strategic_classification" + + task_payloads = map_task_override_to_record_payloads( + task=SimpleNamespace( + id=66, + tenant_id=1, + project_id=77, + title="实现回放", + ai_breakdown_payload={"estimated_hours": 5, "priority": "P2"}, + manual_overrides={"estimated_hours": 8}, + ) + ) + assert len(task_payloads) == 1 + assert task_payloads[0]["source_type"] == "task_override" + assert task_payloads[0]["before_value"] == 5 + assert task_payloads[0]["after_value"] == 8 + + +@pytest.mark.asyncio +async def test_summary_metrics_cover_learning_kpis(db_session) -> None: + base_time = datetime(2025, 1, 1, 10, 0, 0) + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=201, + object_type="task", + object_id=1, + field_name="task_breakdown", + review_status="accepted", + auto_assigned=True, + created_at=base_time, + reviewed_at=base_time + timedelta(minutes=60), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=202, + object_type="task", + object_id=2, + field_name="task_breakdown", + review_status="rejected", + auto_assigned=False, + created_at=base_time + timedelta(minutes=10), + reviewed_at=base_time + timedelta(minutes=40), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=301, + object_type="task", + object_id=1, + field_name="priority", + review_status="accepted", + created_at=base_time + timedelta(minutes=5), + reviewed_at=base_time + timedelta(minutes=20), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=302, + object_type="task", + object_id=1, + field_name="estimated_hours", + review_status="pending", + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=401, + object_type="qa_record", + object_id=7, + field_name="qa_feedback", + review_status="pending", + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="signal_feedback", + source_id=501, + object_type="signal", + object_id=9, + field_name="strategic_classification", + review_status="reviewed", + ), + ] + ) + await db_session.commit() + + target_record = ( + await db_session.execute( + select(FeedbackCenterRecord).where( + FeedbackCenterRecord.tenant_id == 1, + FeedbackCenterRecord.project_id == 11, + FeedbackCenterRecord.source_type == "task_override", + FeedbackCenterRecord.field_name == "priority", + ) + ) + ).scalar_one() + db_session.add_all( + [ + FeedbackCenterRecordRevision( + tenant_id=1, + record_id=target_record.id, + before_snapshot={"review_status": "pending"}, + after_snapshot={"review_status": "reviewed"}, + reviewed_by=9001, + reviewed_at=base_time + timedelta(minutes=25), + ), + FeedbackCenterRecordRevision( + tenant_id=1, + record_id=target_record.id, + before_snapshot={"review_status": "reviewed"}, + after_snapshot={"review_status": "accepted"}, + reviewed_by=9001, + reviewed_at=base_time + timedelta(minutes=30), + ), + ] + ) + await db_session.commit() + + summary = await FeedbackCenterService(db_session).summary(tenant_id=1, project_id=11) + metrics = summary["metrics"] + + assert metrics["accepted_rate_reviewed"] == pytest.approx(2 / 3) + assert metrics["manual_override_rate"] == pytest.approx(0.5) + assert metrics["auto_assigned_rate"] == pytest.approx(0.5) + assert metrics["rework_rate"] == pytest.approx(1 / 6) + assert metrics["avg_review_latency_minutes"] == pytest.approx(35.0) + assert metrics["total_task_breakdown_count"] == 2 + assert metrics["total_task_override_count"] == 2 + assert metrics["total_qa_feedback_count"] == 1 + assert metrics["total_signal_feedback_count"] == 1 + assert metrics["total_revision_count"] == 2 + + +@pytest.mark.asyncio +async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypatch) -> None: + fixed_now = datetime(2025, 1, 15, 12, 0, 0) + monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now) + + records = [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=601, + object_type="task", + object_id=100, + field_name="task_breakdown", + review_status="accepted", + auto_assigned=True, + created_at=datetime(2025, 1, 15, 8, 0, 0), + reviewed_at=datetime(2025, 1, 15, 8, 30, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=602, + object_type="task", + object_id=101, + field_name="task_breakdown", + review_status="rejected", + auto_assigned=False, + created_at=datetime(2025, 1, 14, 8, 0, 0), + reviewed_at=datetime(2025, 1, 14, 8, 40, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=603, + object_type="task", + object_id=100, + field_name="priority", + review_status="pending", + created_at=datetime(2025, 1, 14, 9, 0, 0), + reviewed_at=datetime(2025, 1, 14, 9, 50, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=604, + object_type="qa_record", + object_id=401, + field_name="qa_feedback", + review_status="reviewed", + created_at=datetime(2025, 1, 13, 9, 0, 0), + reviewed_at=datetime(2025, 1, 13, 9, 20, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="signal_feedback", + source_id=605, + object_type="signal", + object_id=501, + field_name="strategic_classification", + review_status="pending", + created_at=datetime(2025, 1, 12, 9, 0, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=606, + object_type="task", + object_id=200, + field_name="task_breakdown", + review_status="accepted", + auto_assigned=False, + created_at=datetime(2025, 1, 8, 8, 0, 0), + reviewed_at=datetime(2025, 1, 8, 8, 20, 0), + ), + ] + db_session.add_all(records) + await db_session.commit() + + target_record = ( + await db_session.execute( + select(FeedbackCenterRecord).where( + FeedbackCenterRecord.tenant_id == 1, + FeedbackCenterRecord.project_id == 11, + FeedbackCenterRecord.source_type == "task_override", + FeedbackCenterRecord.field_name == "priority", + ) + ) + ).scalar_one() + db_session.add_all( + [ + FeedbackCenterRecordRevision( + tenant_id=1, + record_id=target_record.id, + before_snapshot={"review_status": "pending"}, + after_snapshot={"review_status": "reviewed"}, + reviewed_by=9001, + reviewed_at=datetime(2025, 1, 14, 10, 0, 0), + ), + FeedbackCenterRecordRevision( + tenant_id=1, + record_id=target_record.id, + before_snapshot={"review_status": "reviewed"}, + after_snapshot={"review_status": "accepted"}, + reviewed_by=9001, + reviewed_at=datetime(2025, 1, 14, 10, 10, 0), + ), + ] + ) + await db_session.commit() + + summary = await FeedbackCenterService(db_session).summary(tenant_id=1, project_id=11) + trend = summary["trend"] + + assert trend["window_days"] == 7 + assert trend["current_window_start"] == datetime(2025, 1, 9).date() + assert trend["previous_window_start"] == datetime(2025, 1, 2).date() + assert len(trend["daily"]) == 7 + + jan14 = next(item for item in trend["daily"] if item["date"] == "2025-01-14") + assert jan14["total_records"] == 2 + assert jan14["task_breakdown_count"] == 1 + assert jan14["task_override_count"] == 1 + assert jan14["rework_count"] == 1 + assert jan14["avg_review_latency_minutes"] == pytest.approx(45.0) + + current = trend["current"] + previous = trend["previous"] + deltas = trend["deltas"] + + assert current["total_records"] == 5 + assert current["accepted_rate_reviewed"] == pytest.approx(0.5) + assert current["manual_override_rate"] == pytest.approx(0.5) + assert current["auto_assigned_rate"] == pytest.approx(0.5) + assert current["rework_rate"] == pytest.approx(1 / 5) + assert current["avg_review_latency_minutes"] == pytest.approx(35.0) + assert current["revision_count"] == 2 + + assert previous["total_records"] == 1 + assert previous["accepted_rate_reviewed"] == pytest.approx(1.0) + assert previous["manual_override_rate"] == pytest.approx(0.0) + assert previous["auto_assigned_rate"] == pytest.approx(0.0) + assert previous["rework_rate"] == pytest.approx(0.0) + assert previous["avg_review_latency_minutes"] == pytest.approx(20.0) + + assert deltas["total_records_delta"] == 4 + assert deltas["accepted_rate_reviewed_delta"] == pytest.approx(-0.5) + assert deltas["manual_override_rate_delta"] == pytest.approx(0.5) + assert deltas["auto_assigned_rate_delta"] == pytest.approx(0.5) + assert deltas["rework_rate_delta"] == pytest.approx(1 / 5) + assert deltas["avg_review_latency_minutes_delta"] == pytest.approx(15.0) + + +@pytest.mark.asyncio +async def test_summary_trend_respects_source_group_scope(db_session, monkeypatch) -> None: + fixed_now = datetime(2025, 1, 15, 12, 0, 0) + monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now) + + records = [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=701, + object_type="qa_record", + object_id=301, + field_name="qa_feedback", + review_status="accepted", + created_at=datetime(2025, 1, 14, 9, 0, 0), + reviewed_at=datetime(2025, 1, 14, 9, 20, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="signal_feedback", + source_id=702, + object_type="signal", + object_id=401, + field_name="strategic_classification", + review_status="pending", + created_at=datetime(2025, 1, 14, 10, 0, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=703, + object_type="task", + object_id=501, + field_name="task_breakdown", + review_status="accepted", + auto_assigned=True, + created_at=datetime(2025, 1, 13, 8, 0, 0), + reviewed_at=datetime(2025, 1, 13, 8, 25, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=704, + object_type="task", + object_id=501, + field_name="priority", + review_status="reviewed", + created_at=datetime(2025, 1, 13, 9, 0, 0), + reviewed_at=datetime(2025, 1, 13, 9, 30, 0), + ), + ] + db_session.add_all(records) + await db_session.commit() + + service = FeedbackCenterService(db_session) + summary_qa = await service.summary(tenant_id=1, project_id=11, window_days=30, source_group="qa") + + assert summary_qa["total"] == 1 + assert summary_qa["by_source_type"] == {"qa_feedback": 1} + assert summary_qa["metrics"]["total_qa_feedback_count"] == 1 + assert summary_qa["metrics"]["total_task_breakdown_count"] == 0 + assert summary_qa["metrics"]["total_task_override_count"] == 0 + assert summary_qa["metrics"]["total_signal_feedback_count"] == 0 + + trend = summary_qa["trend"] + assert trend["window_days"] == 30 + assert len(trend["daily"]) == 30 + jan14 = next(item for item in trend["daily"] if item["date"] == "2025-01-14") + assert jan14["total_records"] == 1 + assert jan14["qa_feedback_count"] == 1 + assert jan14["task_breakdown_count"] == 0 + + summary_task = await service.summary(tenant_id=1, project_id=11, window_days=7, source_group="task") + assert summary_task["total"] == 2 + assert summary_task["by_source_type"]["task_breakdown"] == 1 + assert summary_task["by_source_type"]["task_override"] == 1 + + +@pytest.mark.asyncio +async def test_summary_supports_30_day_task_scope(db_session, monkeypatch) -> None: + fixed_now = datetime(2025, 1, 15, 12, 0, 0) + monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now) + + db_session.add_all( + [ + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=701, + object_type="task", + object_id=300, + field_name="task_breakdown", + review_status="accepted", + auto_assigned=True, + created_at=datetime(2025, 1, 15, 8, 0, 0), + reviewed_at=datetime(2025, 1, 15, 8, 30, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_breakdown", + source_id=702, + object_type="task", + object_id=301, + field_name="task_breakdown", + review_status="rejected", + auto_assigned=False, + created_at=datetime(2025, 1, 14, 8, 0, 0), + reviewed_at=datetime(2025, 1, 14, 8, 40, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="task_override", + source_id=703, + object_type="task", + object_id=300, + field_name="priority", + review_status="reviewed", + created_at=datetime(2025, 1, 14, 9, 0, 0), + reviewed_at=datetime(2025, 1, 14, 9, 50, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="qa_feedback", + source_id=704, + object_type="qa_record", + object_id=401, + field_name="qa_feedback", + review_status="accepted", + created_at=datetime(2025, 1, 13, 9, 0, 0), + reviewed_at=datetime(2025, 1, 13, 9, 20, 0), + ), + FeedbackCenterRecord( + tenant_id=1, + project_id=11, + source_type="signal_feedback", + source_id=705, + object_type="signal", + object_id=501, + field_name="strategic_classification", + review_status="pending", + created_at=datetime(2025, 1, 12, 9, 0, 0), + ), + ] + ) + await db_session.commit() + + summary = await FeedbackCenterService(db_session).summary( + tenant_id=1, + project_id=11, + window_days=30, + source_group="task", + ) + assert summary["source_group"] == "task" + trend = summary["trend"] + + assert trend["window_days"] == 30 + assert len(trend["daily"]) == 30 + assert trend["current"]["total_records"] == 3 + assert trend["current"]["task_breakdown_count"] == 2 + assert trend["current"]["task_override_count"] == 1 + assert trend["current"]["qa_feedback_count"] == 0 + assert trend["current"]["signal_feedback_count"] == 0 + assert trend["current"]["accepted_rate_reviewed"] == pytest.approx(0.5) + assert trend["current"]["manual_override_rate"] == pytest.approx(0.5) + assert trend["current"]["auto_assigned_rate"] == pytest.approx(0.5) + assert trend["previous"]["total_records"] == 0 + assert summary["by_source_type"] == {"task_breakdown": 2, "task_override": 1} diff --git a/backend/tests/test_prompt_profile_bundle_contract.py b/backend/tests/test_prompt_profile_bundle_contract.py new file mode 100644 index 00000000..08cfb1cd --- /dev/null +++ b/backend/tests/test_prompt_profile_bundle_contract.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from app.config.ai_prompts import build_default_role_prompt_bundle + + +def test_default_role_prompt_bundle_uses_pmflow_pmo_voice() -> None: + bundle = build_default_role_prompt_bundle() + roles = {str(item.get("role_key") or ""): item for item in bundle["roles"]} + + dispatcher = roles.get("pm_dispatcher", {}) + assert "PMFlow 的 PMO 协作中枢" in str(dispatcher.get("system_prompt") or "") + assert "查重、复用/合并、立项评审" in str(dispatcher.get("system_prompt") or "") + assert "MVP" in str(dispatcher.get("system_prompt") or "") + + architect = roles.get("tech_architect", {}) + assert "可行性、依赖、风险边界" in str(architect.get("system_prompt") or "") + assert "最小可行步骤" in str(architect.get("system_prompt") or "") + + coach = roles.get("delivery_coach", {}) + assert "节奏、排期、负责人、推进动作" in str(coach.get("system_prompt") or "") + assert "不超过 3 条" in str(coach.get("system_prompt") or "") diff --git a/frontend/src/api/feedbackCenter.ts b/frontend/src/api/feedbackCenter.ts new file mode 100644 index 00000000..5c873fac --- /dev/null +++ b/frontend/src/api/feedbackCenter.ts @@ -0,0 +1,278 @@ +import { apiRequest } from "./base"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "code" in (raw as ApiEnvelope) && "data" in (raw as ApiEnvelope)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export type FeedbackCenterSourceType = "task_breakdown" | "task_override" | "qa_feedback" | "signal_feedback"; +export type FeedbackCenterReviewStatus = "pending" | "reviewed" | "accepted" | "rejected"; +export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal"; +export type FeedbackCenterTrendWindowDays = 7 | 30; +export type FeedbackCenterSourceGroup = "all" | "task" | "qa" | "signal"; + +export interface FeedbackReasonCandidate { + code: string; + label: string; + description?: string | null; + confidence?: number | null; + source_type?: string | null; + field_name?: string | null; +} + +export interface FeedbackCenterRecordRevision { + id: number; + record_id: number; + before_snapshot: unknown; + after_snapshot: unknown; + reviewed_by?: number | null; + reviewed_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface FeedbackCenterRecord { + id: number; + tenant_id: number; + project_id?: number | null; + source_type: FeedbackCenterSourceType | string; + source_id: number; + object_type: string; + object_id: number; + field_name: string; + source_title?: string | null; + object_title?: string | null; + before_value?: unknown; + after_value?: unknown; + ai_snapshot?: unknown; + ai_confidence?: number | null; + reason_candidates: FeedbackReasonCandidate[]; + reason_code?: string | null; + reason_text?: string | null; + review_status: FeedbackCenterReviewStatus | string; + reviewed_by?: number | null; + reviewed_at?: string | null; + auto_assigned: boolean; + created_at?: string | null; + updated_at?: string | null; + revisions?: FeedbackCenterRecordRevision[]; +} + +export interface FeedbackCenterSummaryReason { + code: string; + label: string; + count: number; +} + +export interface FeedbackCenterSummaryMetrics { + accepted_rate_reviewed: number; + manual_override_rate: number; + rework_rate: number; + auto_assigned_rate: number; + avg_review_latency_minutes: number; + total_task_breakdown_count: number; + total_task_override_count: number; + total_qa_feedback_count: number; + total_signal_feedback_count: number; + total_revision_count: number; +} + +export interface FeedbackCenterTrendDaily { + date: string; + total_records: number; + accepted_count: number; + rejected_count: number; + reviewed_count: number; + pending_count: number; + task_breakdown_count: number; + task_override_count: number; + qa_feedback_count: number; + signal_feedback_count: number; + auto_assigned_count: number; + override_count: number; + rework_count: number; + accepted_rate_reviewed: number; + manual_override_rate: number; + rework_rate: number; + auto_assigned_rate: number; + avg_review_latency_minutes: number; + revision_count: number; +} + +export interface FeedbackCenterTrendWindow { + total_records: number; + accepted_count: number; + rejected_count: number; + reviewed_count: number; + pending_count: number; + task_breakdown_count: number; + task_override_count: number; + qa_feedback_count: number; + signal_feedback_count: number; + auto_assigned_count: number; + override_count: number; + rework_count: number; + accepted_rate_reviewed: number; + manual_override_rate: number; + rework_rate: number; + auto_assigned_rate: number; + avg_review_latency_minutes: number; + revision_count: number; +} + +export interface FeedbackCenterTrendDelta { + total_records_delta: number; + accepted_rate_reviewed_delta: number; + manual_override_rate_delta: number; + rework_rate_delta: number; + auto_assigned_rate_delta: number; + avg_review_latency_minutes_delta: number; +} + +export interface FeedbackCenterTrend { + window_days: number; + current_window_start: string; + previous_window_start: string; + daily: FeedbackCenterTrendDaily[]; + current: FeedbackCenterTrendWindow; + previous: FeedbackCenterTrendWindow; + deltas: FeedbackCenterTrendDelta; +} + +export interface FeedbackCenterSummary { + tenant_id: number; + project_id?: number | null; + source_group: FeedbackCenterSourceGroup | string; + total: number; + backlog_count: number; + pending_count: number; + reviewed_count: number; + accepted_count: number; + rejected_count: number; + accepted_rate: number; + avg_confidence: number; + by_status: Record; + by_source_type: Record; + top_reasons: FeedbackCenterSummaryReason[]; + metrics: FeedbackCenterSummaryMetrics; + trend: FeedbackCenterTrend; +} + +export interface FeedbackCenterSummaryParams { + project_id?: number | string; + window_days?: FeedbackCenterTrendWindowDays; + source_group?: FeedbackCenterSourceGroup; +} + +export interface FeedbackCenterListResponse { + items: FeedbackCenterRecord[]; + total: number; + limit: number; + offset: number; + has_more: boolean; +} + +export interface FeedbackCenterDetailResponse { + record: FeedbackCenterRecord; + revisions: FeedbackCenterRecordRevision[]; +} + +export interface FeedbackCenterReasonCatalogResponse { + items: FeedbackReasonCandidate[]; +} + +export interface FeedbackCenterListParams { + project_id?: number | string; + source_type?: string; + review_status?: string; + field_name?: string; + reason_code?: string; + search?: string; + limit?: number; + offset?: number; +} + +export interface FeedbackCenterReviewPayload { + review_status: Exclude; + reason_code?: string; + reason_text?: string; +} + +function toQuery(params: Record): string { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") return; + query.set(key, String(value)); + }); + const text = query.toString(); + return text ? `?${text}` : ""; +} + +function isFeedbackCenterRecord(value: unknown): value is FeedbackCenterRecord { + if (!value || typeof value !== "object") return false; + const raw = value as Partial; + return typeof raw.id === "number" && typeof raw.source_type === "string" && typeof raw.review_status === "string"; +} + +function normalizeListResponse(raw: FeedbackCenterListResponse): FeedbackCenterListResponse { + const safeItems = Array.isArray(raw.items) ? raw.items.filter(isFeedbackCenterRecord) : []; + return { + items: safeItems, + total: Number(raw.total || safeItems.length), + limit: Number(raw.limit || 40), + offset: Number(raw.offset || 0), + has_more: Boolean(raw.has_more), + }; +} + +export const feedbackCenterApi = { + async getSummary(params: FeedbackCenterSummaryParams = {}): Promise { + const raw = await apiRequest>( + `/ai-steward/feedback-center/summary${toQuery({ + project_id: params.project_id, + window_days: params.window_days, + source_group: params.source_group, + })}` + ); + return unwrap(raw); + }, + async listItems(params: FeedbackCenterListParams = {}): Promise { + const queryParams: Record = { + project_id: params.project_id, + source_type: params.source_type, + review_status: params.review_status, + field_name: params.field_name, + reason_code: params.reason_code, + search: params.search, + limit: params.limit, + offset: params.offset, + }; + const raw = await apiRequest>( + `/ai-steward/feedback-center/items${toQuery(queryParams)}` + ); + return normalizeListResponse(unwrap(raw)); + }, + async getDetail(recordId: number, projectId?: number | string): Promise { + const raw = await apiRequest>( + `/ai-steward/feedback-center/items/${recordId}${toQuery({ project_id: projectId })}` + ); + return unwrap(raw); + }, + async reviewItem(recordId: number, payload: FeedbackCenterReviewPayload): Promise { + const raw = await apiRequest>(`/ai-steward/feedback-center/items/${recordId}/review`, { + method: "POST", + body: JSON.stringify(payload), + }); + return unwrap(raw); + }, + async getReasonCatalog(sourceType?: string, fieldName?: string): Promise { + const raw = await apiRequest>( + `/ai-steward/feedback-center/reason-catalog${toQuery({ source_type: sourceType, field_name: fieldName })}` + ); + return unwrap(raw); + }, +}; diff --git a/frontend/src/components/sstc/FeedbackSummaryCard.tsx b/frontend/src/components/sstc/FeedbackSummaryCard.tsx new file mode 100644 index 00000000..72da7921 --- /dev/null +++ b/frontend/src/components/sstc/FeedbackSummaryCard.tsx @@ -0,0 +1,125 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { feedbackCenterApi } from "@/api/feedbackCenter"; +import type { SSTCTask } from "@/api/sstc"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface FeedbackSummaryCardProps { + projectId: string; + compact?: boolean; + tasks?: SSTCTask[]; +} + +function hasAIBreakdown(task: SSTCTask): boolean { + return !!task.ai_breakdown_payload && typeof task.ai_breakdown_payload === "object"; +} + +function getOverrideFields(task: SSTCTask): string[] { + const overrides = task.manual_overrides; + if (!overrides || typeof overrides !== "object") return []; + return Object.keys(overrides); +} + +export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: FeedbackSummaryCardProps) { + const navigate = useNavigate(); + const numericProjectId = Number(projectId); + const localSummary = useMemo(() => { + const aiTasks = tasks.filter((task) => hasAIBreakdown(task)); + if (!aiTasks.length) { + return { + pending: 0, + acceptedRate: 0, + topReasons: [] as Array<{ label: string; count: number }>, + }; + } + let accepted = 0; + let rejected = 0; + let pending = 0; + const reasonCountMap = new Map(); + for (const task of aiTasks) { + const overrides = getOverrideFields(task); + if (overrides.length > 0) { + rejected += 1; + overrides.forEach((field) => { + reasonCountMap.set(field, Number(reasonCountMap.get(field) || 0) + 1); + }); + continue; + } + if (task.auto_assigned) { + accepted += 1; + } else { + pending += 1; + } + } + const reviewed = accepted + rejected; + return { + pending, + acceptedRate: reviewed > 0 ? accepted / reviewed : 0, + topReasons: Array.from(reasonCountMap.entries()) + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3), + }; + }, [tasks]); + + const summaryQuery = useQuery({ + queryKey: ["feedback-center", "summary", numericProjectId], + queryFn: () => feedbackCenterApi.getSummary({ project_id: numericProjectId }), + enabled: Number.isFinite(numericProjectId) && numericProjectId > 0, + }); + + const pendingCount = localSummary.pending > 0 ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0; + const acceptedRate = localSummary.acceptedRate > 0 ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0); + const topReasons = localSummary.topReasons.length + ? localSummary.topReasons + : (summaryQuery.data?.top_reasons || []).map((item) => ({ label: item.label, count: item.count })); + + return ( + + +
+
+

Learning Signal

+

统一反馈中心摘要

+

+ 这里不做复核,只告诉你当前自动化链路里哪些地方最常被人工纠正。 +

+
+ +
+ +
+
+

待复核

+

{pendingCount}

+
+
+

采纳率

+

{Math.round(acceptedRate * 100)}%

+
+
+

高频原因

+
+ {topReasons.slice(0, 3).map((item) => ( + + {item.label} · {item.count} + + ))} + {topReasons.length === 0 && 暂无} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx b/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx index 462f3c2e..cdf0f475 100644 --- a/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx +++ b/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx @@ -5,6 +5,7 @@ import type { User } from "@/api/users"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard"; import { Dialog, DialogContent, @@ -26,6 +27,7 @@ interface PatchTaskStatusMutationLike { } interface SSTCExecutionBoardPanelProps { + projectId: string; stageLocked: boolean; projectUsers: User[]; optimizeFeedback: string; @@ -62,6 +64,7 @@ function getOverrideFields(task: SSTCTask): string[] { } export function SSTCExecutionBoardPanel({ + projectId, stageLocked, projectUsers, optimizeFeedback, @@ -78,6 +81,10 @@ export function SSTCExecutionBoardPanel({ ); const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null; const detailOverrides = detailTask ? getOverrideFields(detailTask) : []; + const laneTasks = useMemo( + () => EXECUTION_LANES.flatMap((lane) => executionLanes[lane.key] || []), + [executionLanes] + ); return ( @@ -121,6 +128,9 @@ export function SSTCExecutionBoardPanel({ {optimizeFeedback}

)} +
+ +
{EXECUTION_LANES.map((lane) => ( diff --git a/frontend/src/components/sstc/SSTCTaskStagePanel.tsx b/frontend/src/components/sstc/SSTCTaskStagePanel.tsx index 22da5ce4..1c4aeefd 100644 --- a/frontend/src/components/sstc/SSTCTaskStagePanel.tsx +++ b/frontend/src/components/sstc/SSTCTaskStagePanel.tsx @@ -9,6 +9,7 @@ import type { SSTCTaskRecommendation, } from "@/api/sstc"; import type { User } from "@/api/users"; +import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard"; import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -247,6 +248,9 @@ export function SSTCTaskStagePanel({ {optimizeFeedback}
)} +
+ +
{selectedCount > 0 && (
已选择 {selectedCount} 项任务 diff --git a/frontend/src/components/sstc/SSTCWorkspace.tsx b/frontend/src/components/sstc/SSTCWorkspace.tsx index b49a917d..0fcb982d 100644 --- a/frontend/src/components/sstc/SSTCWorkspace.tsx +++ b/frontend/src/components/sstc/SSTCWorkspace.tsx @@ -113,6 +113,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { )} {props.activeStage === "execution" && ( s.user); + const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS"); // Project creation state const [input, setInput] = useState(""); @@ -47,7 +94,16 @@ export default function AIStewardPage() { const [error, setError] = useState(null); // AI Steward features state - const [activeTab, setActiveTab] = useState("overview"); + const [activeTab, setActiveTab] = useState(() => normalizeTab(searchParams.get("tab"))); + const [feedbackSection, setFeedbackSection] = useState(() => + normalizeSection(searchParams.get("section")) + ); + const [trendWindowDays, setTrendWindowDays] = useState(() => + normalizeTrendWindow(searchParams.get("window")) + ); + const [trendSourceGroup, setTrendSourceGroup] = useState(() => + normalizeTrendSourceGroup(searchParams.get("scope")) + ); const [conflicts, setConflicts] = useState([]); const [blockers, setBlockers] = useState([]); const [briefing, setBriefing] = useState(null); @@ -422,8 +478,68 @@ export default function AIStewardPage() { } }; + const syncUrlParams = ( + tab: AIStewardTab, + section: FeedbackCenterSection, + nextWindowDays: FeedbackCenterTrendWindowDays = trendWindowDays, + nextSourceGroup: FeedbackCenterSourceGroup = trendSourceGroup + ) => { + const next = new URLSearchParams(searchParams); + next.set("tab", tab); + next.set("section", section); + next.set("window", String(nextWindowDays)); + next.set("scope", nextSourceGroup); + setSearchParams(next, { replace: true }); + }; + + const handleTabChange = (value: string) => { + const nextTab = normalizeTab(value); + if (nextTab === "feedback-center" && !canViewFeedbackCenter) { + setActiveTab("overview"); + syncUrlParams("overview", "overview"); + return; + } + setActiveTab(nextTab); + syncUrlParams(nextTab, feedbackSection); + }; + + const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => { + setFeedbackSection(nextSection); + syncUrlParams(activeTab, nextSection); + }; + + const handleTrendWindowDaysChange = (nextWindowDays: FeedbackCenterTrendWindowDays) => { + setTrendWindowDays(nextWindowDays); + syncUrlParams(activeTab, feedbackSection, nextWindowDays, trendSourceGroup); + }; + + const handleTrendSourceGroupChange = (nextSourceGroup: FeedbackCenterSourceGroup) => { + setTrendSourceGroup(nextSourceGroup); + syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup); + }; + // ==================== Initial Load ==================== + useEffect(() => { + const urlTab = normalizeTab(searchParams.get("tab")); + const urlSection = normalizeSection(searchParams.get("section")); + const safeTab = urlTab === "feedback-center" && !canViewFeedbackCenter ? "overview" : urlTab; + if (safeTab !== activeTab) { + setActiveTab(safeTab); + } + if (urlSection !== feedbackSection) { + setFeedbackSection(urlSection); + } + const urlWindow = normalizeTrendWindow(searchParams.get("window")); + if (urlWindow !== trendWindowDays) { + setTrendWindowDays(urlWindow); + } + const urlSourceGroup = normalizeTrendSourceGroup(searchParams.get("scope")); + if (urlSourceGroup !== trendSourceGroup) { + setTrendSourceGroup(urlSourceGroup); + } + }, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]); + useEffect(() => { if (activeTab === "conflicts") loadConflicts(); if (activeTab === "blockers") loadBlockers(); @@ -452,12 +568,12 @@ export default function AIStewardPage() {

- setActiveTab(tab)} /> + handleTabChange(tab)} /> {/* Main Content */} - + 总览 零配置创建 @@ -466,6 +582,9 @@ export default function AIStewardPage() { 阻塞检测 每日简报 问答记录 + {canViewFeedbackCenter && ( + 反馈中心 + )} 引擎配置 @@ -511,6 +630,18 @@ export default function AIStewardPage() { + {canViewFeedbackCenter && ( + + )} + +

{title}

+
{formatJson(value)}
+
+ ); +} + +export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) { + if (!record) { + return ( +
+ 从左侧选择一条记录,这里会展示 AI 原始结果、人工修正 diff 和修订历史。 +
+ ); + } + + return ( +
+
+
+

Diff View

+

+ {record.object_title || record.source_title || `记录 #${record.id}`} +

+

+ {record.source_type} · 字段 {record.field_name} +

+
+
+ 状态 {record.review_status} + {record.auto_assigned && ( + AI 自动指派 + )} +
+
+ +
+
+

Meta

+
+
+

项目

+

{record.project_id ?? "—"}

+
+
+

对象

+

{record.object_type} #{record.object_id}

+
+
+

原因码

+

{record.reason_code || "—"}

+
+
+

置信度

+

{record.ai_confidence !== null && record.ai_confidence !== undefined ? `${Math.round(Number(record.ai_confidence) * 100)}%` : "—"}

+
+
+
+
+

AI Snapshot

+
+            {formatJson(record.ai_snapshot)}
+          
+
+
+ +
+ {diffCard("AI Before", record.before_value, "before")} + {diffCard("Human After", record.after_value, "after")} +
+ +
+
+

Revisions

+ {revisions.length} 条 +
+ {!revisions.length &&

暂无修订历史。

} + {!!revisions.length && ( +
+ {revisions.map((revision) => ( +
+

+ 修订 #{revision.id} + {revision.reviewed_at ? ` · ${revision.reviewed_at}` : ""} +

+
+
+                    {formatJson(revision.before_snapshot)}
+                  
+
+                    {formatJson(revision.after_snapshot)}
+                  
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx b/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx new file mode 100644 index 00000000..d153668e --- /dev/null +++ b/frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx @@ -0,0 +1,131 @@ +import type { FeedbackCenterRecord } from "@/api/feedbackCenter"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; + +interface FeedbackListProps { + items: FeedbackCenterRecord[]; + selectedId?: number | null; + search: string; + onSearchChange: (value: string) => void; + sourceType: string; + reviewStatus: string; + onSourceTypeChange: (value: string) => void; + onReviewStatusChange: (value: string) => void; + onSelect: (item: FeedbackCenterRecord) => void; + total: number; + isLoading: boolean; +} + +function statusTone(status: string): string { + if (status === "accepted") return "border-emerald-200 bg-emerald-50 text-emerald-700"; + if (status === "rejected") return "border-rose-200 bg-rose-50 text-rose-700"; + if (status === "reviewed") return "border-sky-200 bg-sky-50 text-sky-700"; + return "border-amber-200 bg-amber-50 text-amber-700"; +} + +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 || "未知"; +} + +export function FeedbackList({ + items, + selectedId, + search, + onSearchChange, + sourceType, + reviewStatus, + onSourceTypeChange, + onReviewStatusChange, + onSelect, + total, + isLoading, +}: FeedbackListProps) { + return ( +
+
+
+
+

Queue

+

待复核记录

+
+ onSearchChange(event.target.value)} + placeholder="搜索任务标题、问题、信号..." + className="border-slate-200" + /> +
+ + +
+

共 {total} 条记录

+
+
+ +
+ {isLoading &&

加载中...

} + {!isLoading && !items.length &&

当前筛选下还没有反馈记录。

} + {items.map((item) => { + const selected = selectedId === item.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/components/feedback-center/MetricsPanel.tsx b/frontend/src/pages/ai-steward/components/feedback-center/MetricsPanel.tsx new file mode 100644 index 00000000..21abfae4 --- /dev/null +++ b/frontend/src/pages/ai-steward/components/feedback-center/MetricsPanel.tsx @@ -0,0 +1,268 @@ +import type { FeedbackCenterSummary } from "@/api/feedbackCenter"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; + +interface MetricsPanelProps { + summary?: FeedbackCenterSummary | null; +} + +const EMPTY_METRICS = { + accepted_rate_reviewed: 0, + manual_override_rate: 0, + rework_rate: 0, + auto_assigned_rate: 0, + avg_review_latency_minutes: 0, + total_task_breakdown_count: 0, + total_task_override_count: 0, + total_qa_feedback_count: 0, + total_signal_feedback_count: 0, + total_revision_count: 0, +}; + +const SOURCE_LABELS: Record = { + task_breakdown: "任务拆解", + task_override: "任务覆盖", + qa_feedback: "QA 反馈", + signal_feedback: "信号反馈", +}; + +const STATUS_LABELS: Record = { + pending: "待复核", + reviewed: "已复核", + accepted: "已采纳", + rejected: "已拒绝", +}; + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function formatMinutes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0m"; + if (value < 60) return `${Math.round(value)}m`; + const hours = Math.floor(value / 60); + const minutes = Math.round(value % 60); + return `${hours}h ${minutes}m`; +} + +function StatGridCard({ + title, + value, + helper, +}: { + title: string; + value: string; + helper: string; +}) { + return ( +
+

{title}

+

{value}

+

{helper}

+
+ ); +} + +function ProgressRows({ + title, + items, + tone = "slate", + totalLabel, +}: { + title: string; + items: Array<{ label: string; count: number; percent: number }>; + tone?: "slate" | "emerald" | "amber" | "rose" | "sky"; + totalLabel?: string; +}) { + const filledClass = { + slate: "bg-slate-900", + emerald: "bg-emerald-500", + amber: "bg-amber-500", + rose: "bg-rose-500", + sky: "bg-sky-500", + }[tone]; + const emptyClass = { + slate: "bg-slate-200", + emerald: "bg-emerald-100", + amber: "bg-amber-100", + rose: "bg-rose-100", + sky: "bg-sky-100", + }[tone]; + + return ( + + +
+
+

{title}

+ {totalLabel &&

{totalLabel}

} +
+ + {items.length} 项 + +
+
+ {items.length > 0 ? ( + items.map((item) => { + const filled = item.percent <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(item.percent * 10))); + return ( +
+
+ {item.label} + {item.count} +
+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+

{formatPercent(item.percent)}

+
+ ); + }) + ) : ( +

暂无可展示的分布数据。

+ )} +
+
+
+ ); +} + +export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) { + const metrics = summary?.metrics ?? EMPTY_METRICS; + const total = summary?.total ?? 0; + const bySourceType = summary?.by_source_type || {}; + const byStatus = summary?.by_status || {}; + const sourceTotal = Object.values(bySourceType).reduce((acc, value) => acc + value, 0) || 0; + const statusTotal = Object.values(byStatus).reduce((acc, value) => acc + value, 0) || 0; + + const sourceRows = Object.entries(bySourceType) + .map(([label, count]) => ({ + label: SOURCE_LABELS[label] || label, + count, + percent: sourceTotal > 0 ? count / sourceTotal : 0, + })) + .sort((a, b) => b.count - a.count); + + const statusRows = Object.entries(byStatus) + .map(([label, count]) => ({ + label: STATUS_LABELS[label] || label, + count, + percent: statusTotal > 0 ? count / statusTotal : 0, + })) + .sort((a, b) => b.count - a.count); + + const reviewDecisionTotal = (summary?.accepted_count || 0) + (summary?.rejected_count || 0); + + return ( +
+
+ + + + + + +
+ +
+ + +

Learning Health

+

学习健康度

+

把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。

+
+
+

采纳率

+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+

{formatPercent(metrics.accepted_rate_reviewed)}

+
+
+

人工覆盖

+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+

{formatPercent(metrics.manual_override_rate)}

+
+
+

返工

+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+

{formatPercent(metrics.rework_rate)}

+
+
+

自动指派

+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+

{formatPercent(metrics.auto_assigned_rate)}

+
+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/ai-steward/components/feedback-center/ReasonPicker.tsx b/frontend/src/pages/ai-steward/components/feedback-center/ReasonPicker.tsx new file mode 100644 index 00000000..150e7d06 --- /dev/null +++ b/frontend/src/pages/ai-steward/components/feedback-center/ReasonPicker.tsx @@ -0,0 +1,142 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + FeedbackCenterRecord, + FeedbackCenterReviewStatus, + FeedbackReasonCandidate, +} from "@/api/feedbackCenter"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface ReasonPickerProps { + record: FeedbackCenterRecord | null; + catalog: FeedbackReasonCandidate[]; + isPending: boolean; + onSubmit: (payload: { + review_status: Exclude; + reason_code?: string; + reason_text?: string; + }) => Promise | void; +} + +export function ReasonPicker({ record, catalog, isPending, onSubmit }: ReasonPickerProps) { + const [selectedReason, setSelectedReason] = useState(""); + const [reasonText, setReasonText] = useState(""); + const [targetStatus, setTargetStatus] = useState>("accepted"); + + const candidates = useMemo(() => { + if (!record) return []; + const base = Array.isArray(record.reason_candidates) ? record.reason_candidates : []; + const merged = [...base]; + catalog.forEach((item) => { + if (merged.some((candidate) => candidate.code === item.code)) return; + merged.push(item); + }); + return merged; + }, [catalog, record]); + + useEffect(() => { + setSelectedReason(record?.reason_code || ""); + setReasonText(record?.reason_text || ""); + if (record?.review_status === "reviewed" || record?.review_status === "rejected") { + setTargetStatus(record.review_status); + return; + } + setTargetStatus("accepted"); + }, [record?.id, record?.reason_code, record?.reason_text, record?.review_status]); + + if (!record) { + return ( +
+ 选择左侧记录后,这里会显示候选原因与学习确认操作。 +
+ ); + } + + return ( +
+
+

Review Action

+

确认学习原因

+

AI 已经先给出候选原因,人只需要确认、修正,或拒绝纳入学习。

+
+ +
+
+ {(["reviewed", "accepted", "rejected"] as const).map((status) => ( + + ))} +
+ +
+

候选原因

+
+ {candidates.map((candidate) => { + const active = selectedReason === candidate.code; + return ( + + ); + })} +
+ {!candidates.length && ( + + 当前没有自动候选,请手工补充原因 + + )} +
+ +
+

补充备注

+