3027 lines
111 KiB
Markdown
3027 lines
111 KiB
Markdown
# Business Analysis Agent Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the read-only Business Analysis Agent so `/wenfan-xiaobao` and product/project/version pages can answer business data questions with Insight Cards, ECharts-rendered charts, structured reports, clickable evidence, and read-only follow-ups.
|
||
|
||
**Architecture:** Shared contracts define the platform AnalysisPlan, MetricResult, Evidence, Report, FollowUp, and UnifiedChartSpec types. The backend adds an `ai/analysis` pipeline under the existing `AiModule`: Semantic Layer -> Analysis Strategy -> Analysis Plan Processor -> Permission Scope Resolver -> Metric Engine -> response builders. The frontend adds an Analysis API client, a renderer-agnostic ChartSpec-to-ECharts boundary, Apple Vision style analysis components, and surfaces in the AI Assistant plus context pages.
|
||
|
||
**Tech Stack:** TypeScript, NestJS, Prisma, PostgreSQL, class-validator, Jest, Next.js App Router, React, Tailwind CSS, Zustand-backed auth/session headers, ECharts via `echarts` and `echarts-for-react`, Node test runner for frontend lib tests.
|
||
|
||
## Global Constraints
|
||
|
||
- Business Analysis Agent is read-only and must not mutate Product, Project, Version, Requirement, VersionPlan, DevTask, TestCase, Bug, Member, WorkActivity, TaskWorklog, Overtime, or Xiaobao data.
|
||
- AI must not write SQL and must not directly query the database.
|
||
- AI Planning may only propose an `AnalysisPlan`; `AnalysisPlanProcessor` must validate and normalize before execution.
|
||
- Analysis queries may only read data already allowed by the current user's permissions and page context.
|
||
- ChartSpec is a platform contract, not an ECharts option object.
|
||
- Frontend renderer uses ECharts first, behind a single `ChartRenderer` boundary.
|
||
- User-visible confidence is level-based: `high` / `medium` / `low` for semantic confidence and `sufficient` / `partial` / `insufficient` for data confidence.
|
||
- Ranking plans must include a Top N limit; default Top 10, maximum Top 20.
|
||
- Default last 30 days applies only to unspecified trend, throughput, efficiency, effort, and change questions.
|
||
- Current state, risk, and lifecycle questions must not be forced into last 30 days.
|
||
- No Data Strategy must return a structured response and must not invent analysis text.
|
||
- First-version follow-ups are read-only: `question`, `drilldown`, and `export`.
|
||
- AI Analysis Design System uses Apple Vision style: large whitespace, large radius, light shadow, translucent material, restrained color, number-first cards, smooth line-area charts, rounded horizontal bars, minimal axes, and no BI big-screen styling.
|
||
- New business analysis data must not add an AppData key.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Shared Contract
|
||
|
||
- Create `packages/shared/src/analysis.ts`: all analysis contracts shared by server and web.
|
||
- Modify `packages/shared/src/index.ts`: export `analysis`.
|
||
|
||
### Backend
|
||
|
||
- Create `apps/server/src/modules/ai/dto/analysis.dto.ts`: request DTO for `POST /api/v1/ai/analysis`.
|
||
- Create `apps/server/src/modules/ai/prompts/analysis-plan.ts`: tool prompt/schema for AI Planning Strategy.
|
||
- Create `apps/server/src/modules/ai/prompts/analysis-report.ts`: tool prompt/schema for optional AI report text over deterministic Metric Result.
|
||
- Create `apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts`: phrase-to-semantic concept mapping and confidence.
|
||
- Create `apps/server/src/modules/ai/analysis/metric-catalog.ts`: metric catalog and helpers.
|
||
- Create `apps/server/src/modules/ai/analysis/analysis-strategy.ts`: Template Strategy, deterministic Rule Composition Strategy, AI Planning fallback boundary.
|
||
- Create `apps/server/src/modules/ai/analysis/analysis-plan-processor.ts`: validate and normalize.
|
||
- Create `apps/server/src/modules/ai/analysis/permission-scope-resolver.ts`: resolves `DataScope` from current user, permissions, and context.
|
||
- Create `apps/server/src/modules/ai/analysis/metric-engine.ts`: executes approved MVP metrics from relation tables.
|
||
- Create `apps/server/src/modules/ai/analysis/chart-spec-builder.ts`: converts Metric Result to Unified ChartSpec.
|
||
- Create `apps/server/src/modules/ai/analysis/insight-engine.ts`: deterministic Insight Card generation.
|
||
- Create `apps/server/src/modules/ai/analysis/report-builder.ts`: deterministic report fallback and AI-enhanced report integration.
|
||
- Create `apps/server/src/modules/ai/analysis/follow-up-builder.ts`: read-only follow-up generation.
|
||
- Create `apps/server/src/modules/ai/analysis/business-analysis.service.ts`: orchestrates the pipeline.
|
||
- Modify `apps/server/src/modules/ai/ai.controller.ts`: add `POST /analysis`.
|
||
- Modify `apps/server/src/modules/ai/ai.module.ts`: import `CommonDomainModule`, provide analysis services.
|
||
- Test files:
|
||
- Create `apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts`
|
||
- Create `apps/server/src/modules/ai/analysis/metric-catalog.spec.ts`
|
||
- Create `apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts`
|
||
- Create `apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts`
|
||
- Create `apps/server/src/modules/ai/analysis/metric-engine.spec.ts`
|
||
- Create `apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts`
|
||
- Modify `apps/server/src/modules/ai/ai.service.spec.ts` only if report/planning helpers require existing provider behavior coverage.
|
||
|
||
### Frontend
|
||
|
||
- Modify `apps/web/package.json`: add `echarts` and `echarts-for-react`.
|
||
- Lockfile update: `pnpm-lock.yaml` via `pnpm add --filter web echarts echarts-for-react`.
|
||
- Create `apps/web/lib/analysis-api.ts`: typed API client for `/ai/analysis`.
|
||
- Create `apps/web/lib/analysis-chart-renderer.ts`: pure `UnifiedChartSpec -> EChartsOption` conversion.
|
||
- Create `apps/web/lib/analysis-chart-renderer.test.ts`: verifies renderer boundary and Apple Vision options.
|
||
- Create `apps/web/components/analysis/InsightCard.tsx`
|
||
- Create `apps/web/components/analysis/AnalysisChart.tsx`
|
||
- Create `apps/web/components/analysis/AnalysisReport.tsx`
|
||
- Create `apps/web/components/analysis/EvidenceList.tsx`
|
||
- Create `apps/web/components/analysis/FollowUpActions.tsx`
|
||
- Create `apps/web/components/analysis/AnalysisResultBlock.tsx`
|
||
- Create `apps/web/components/analysis/AnalysisEntryButton.tsx`
|
||
- Create `apps/web/components/analysis/AnalysisContextDrawer.tsx`
|
||
- Modify `apps/web/app/wenfan-xiaobao/page.tsx`: add business-analysis message type and call `analysis-api`.
|
||
- Modify `apps/web/app/products/[id]/page.tsx`: add product context analysis entry.
|
||
- Modify `apps/web/app/projects/[id]/page.tsx`: add project context analysis entry.
|
||
- Modify `apps/web/app/versions/[id]/page.tsx`: add version context analysis entry.
|
||
- Modify `apps/web/lib/api.test.ts`: cover `/ai/analysis` headers if `analysis-api` uses `api.postRaw`.
|
||
- Create `apps/web/lib/analysis-api.test.ts`: response handling and no-data/no-permission branches.
|
||
|
||
### Documentation
|
||
|
||
- Modify `docs/agent-spec.md`: add implementation status once feature lands.
|
||
- Modify `docs/roadmap.md`: move V3.4 from design-confirmed to implemented when all verification passes.
|
||
- Modify `docs/workflow.md`: add Business Analysis Agent operational notes if implementation changes the planned workflow.
|
||
|
||
---
|
||
|
||
## Task 1: Shared Analysis Contracts
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/analysis.ts`
|
||
- Modify: `packages/shared/src/index.ts`
|
||
- Test: `packages/shared/src/analysis.ts` through `pnpm --filter @ftb/shared type-check`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the spec contract in `docs/superpowers/specs/2026-07-08-business-analysis-agent-design.md`.
|
||
- Produces:
|
||
- `AnalysisRequest`
|
||
- `AnalysisResponse`
|
||
- `AnalysisPlan`
|
||
- `MetricDefinition`
|
||
- `MetricResult`
|
||
- `UnifiedChartSpec`
|
||
- `InsightCard`
|
||
- `AnalysisReport`
|
||
- `EvidenceItem`
|
||
- `FollowUp`
|
||
|
||
- [ ] **Step 1: Write the shared contract file**
|
||
|
||
Create `packages/shared/src/analysis.ts` with these exported types:
|
||
|
||
```ts
|
||
export type AnalysisSurface = 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
|
||
|
||
export type MetricId =
|
||
| 'version_risk_score'
|
||
| 'completion_trend'
|
||
| 'overdue_item_count'
|
||
| 'requirement_status_count'
|
||
| 'requirement_completion_count'
|
||
| 'requirement_source_count'
|
||
| 'department_workload'
|
||
| 'member_pending_work'
|
||
| 'member_effort_hours'
|
||
| 'bug_severity_count'
|
||
| 'test_pass_rate'
|
||
| 'overtime_reason_hours'
|
||
| 'delay_rate'
|
||
| 'delay_reason_count';
|
||
|
||
export type DimensionId =
|
||
| 'product'
|
||
| 'project'
|
||
| 'version'
|
||
| 'requirement_status'
|
||
| 'requirement_type'
|
||
| 'requirement_source'
|
||
| 'department'
|
||
| 'member'
|
||
| 'role'
|
||
| 'month'
|
||
| 'week'
|
||
| 'day'
|
||
| 'bug_severity'
|
||
| 'bug_status'
|
||
| 'test_status'
|
||
| 'delay_reason';
|
||
|
||
export type AnalysisType =
|
||
| 'ranking'
|
||
| 'trend'
|
||
| 'comparison'
|
||
| 'distribution'
|
||
| 'composition'
|
||
| 'correlation'
|
||
| 'breakdown'
|
||
| 'summary';
|
||
|
||
export type TimePolicy = 'current_state' | 'last_30_days' | 'lifecycle' | 'user_required' | 'explicit_range';
|
||
|
||
export type ChartKind =
|
||
| 'number_card'
|
||
| 'line_area'
|
||
| 'horizontal_bar'
|
||
| 'stacked_horizontal_bar'
|
||
| 'donut'
|
||
| 'table_preview';
|
||
|
||
export interface MetricRef {
|
||
metricId: MetricId;
|
||
version: number;
|
||
}
|
||
|
||
export interface MetricDefinition {
|
||
metricId: MetricId;
|
||
version: number;
|
||
name: string;
|
||
description: string;
|
||
formula: string;
|
||
owner: string;
|
||
supportedDimensions: DimensionId[];
|
||
supportedAnalysisTypes: AnalysisType[];
|
||
defaultChart: ChartKind;
|
||
defaultDimension?: DimensionId;
|
||
defaultTimePolicy: TimePolicy;
|
||
status: 'active' | 'deprecated';
|
||
}
|
||
|
||
export type DataScope =
|
||
| { type: 'self'; userId: string }
|
||
| { type: 'managed_projects'; projectIds: string[] }
|
||
| { type: 'product'; productId: string }
|
||
| { type: 'project'; projectId: string }
|
||
| { type: 'version'; versionId: string }
|
||
| { type: 'system'; reason: 'admin' | 'management_permission' };
|
||
|
||
export interface AnalysisPlan {
|
||
metricRef: MetricRef;
|
||
analysisType: AnalysisType;
|
||
dimensions: DimensionId[];
|
||
timeRange?: {
|
||
start: string;
|
||
end: string;
|
||
policy: TimePolicy;
|
||
};
|
||
filters: Record<string, string | number | boolean | string[] | number[]>;
|
||
scope: DataScope;
|
||
limit?: number;
|
||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||
}
|
||
|
||
export interface EvidenceItem {
|
||
label: string;
|
||
value: string | number;
|
||
unit?: string;
|
||
sourceDomain:
|
||
| 'product'
|
||
| 'project'
|
||
| 'version'
|
||
| 'requirement'
|
||
| 'version_plan'
|
||
| 'dev_task'
|
||
| 'test_case'
|
||
| 'bug'
|
||
| 'work_activity'
|
||
| 'task_worklog'
|
||
| 'overtime'
|
||
| 'xiaobao';
|
||
sourceLabel?: string;
|
||
drilldown?: {
|
||
type: 'list' | 'detail';
|
||
target: string;
|
||
filters: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
export interface MetricResult {
|
||
metricRef: MetricRef;
|
||
analysisType: AnalysisType;
|
||
columns: Array<{ id: string; label: string; type: 'string' | 'number' | 'date' | 'percent' }>;
|
||
rows: Array<Record<string, string | number | null>>;
|
||
totals?: Record<string, string | number>;
|
||
comparison?: {
|
||
baselineLabel: string;
|
||
currentLabel: string;
|
||
deltaValue?: number;
|
||
deltaPercent?: number;
|
||
};
|
||
evidence: EvidenceItem[];
|
||
dataScope: DataScope;
|
||
generatedAt: string;
|
||
}
|
||
|
||
export interface UnifiedChartSpec {
|
||
kind: ChartKind;
|
||
title: string;
|
||
subtitle?: string;
|
||
dataset: {
|
||
source: Array<Record<string, string | number | null>>;
|
||
x?: string;
|
||
y?: string;
|
||
series?: string;
|
||
value?: string;
|
||
label?: string;
|
||
};
|
||
encoding: {
|
||
x?: { field: string; label: string };
|
||
y?: { field: string; label: string };
|
||
value?: { field: string; label: string; unit?: string };
|
||
color?: { mode: 'single' | 'risk' | 'semantic'; field?: string };
|
||
};
|
||
annotations?: Array<{
|
||
type: 'outlier' | 'peak' | 'target' | 'threshold';
|
||
label: string;
|
||
value?: string | number;
|
||
field?: string;
|
||
}>;
|
||
stylePreset: 'apple_vision_light' | 'apple_vision_dark';
|
||
}
|
||
|
||
export interface InsightCard {
|
||
summary: string;
|
||
primaryValue?: {
|
||
label: string;
|
||
value: string | number;
|
||
unit?: string;
|
||
};
|
||
comparison?: {
|
||
label: string;
|
||
direction: 'up' | 'down' | 'flat';
|
||
value: string | number;
|
||
tone: 'positive' | 'negative' | 'neutral';
|
||
};
|
||
semanticConfidence: 'high' | 'medium' | 'low';
|
||
dataConfidence: 'sufficient' | 'partial' | 'insufficient';
|
||
}
|
||
|
||
export interface AnalysisReport {
|
||
summary: string;
|
||
keyFindings: string[];
|
||
evidence: EvidenceItem[];
|
||
suggestions: string[];
|
||
dataScope: {
|
||
timeDescription: string;
|
||
permissionDescription: string;
|
||
metricFormulaDescription: string;
|
||
generatedAt: string;
|
||
};
|
||
}
|
||
|
||
export type FollowUp =
|
||
| { type: 'question'; label: string; prompt: string }
|
||
| { type: 'drilldown'; label: string; target: string; filters: Record<string, unknown> }
|
||
| { type: 'export'; label: string; format: 'png' | 'pdf' | 'csv' };
|
||
|
||
export interface AnalysisRequest {
|
||
question: string;
|
||
context?: {
|
||
surface: AnalysisSurface;
|
||
productId?: string;
|
||
projectId?: string;
|
||
versionId?: string;
|
||
};
|
||
}
|
||
|
||
export type AnalysisErrorCode =
|
||
| 'NO_PERMISSION'
|
||
| 'UNSUPPORTED_ANALYSIS'
|
||
| 'AMBIGUOUS_INTENT'
|
||
| 'NO_DATA'
|
||
| 'AI_UNAVAILABLE'
|
||
| 'INVALID_PLAN';
|
||
|
||
export type AnalysisResponse =
|
||
| {
|
||
ok: true;
|
||
plan: AnalysisPlan;
|
||
metricResult: MetricResult;
|
||
insight: InsightCard;
|
||
chart: UnifiedChartSpec;
|
||
report: AnalysisReport;
|
||
followUps: FollowUp[];
|
||
}
|
||
| {
|
||
ok: false;
|
||
code: AnalysisErrorCode;
|
||
message: string;
|
||
clarificationOptions?: Array<{ label: string; prompt: string }>;
|
||
dataScope?: DataScope;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Export the shared contract**
|
||
|
||
Modify `packages/shared/src/index.ts`:
|
||
|
||
```ts
|
||
export * from './enums';
|
||
export * from './types';
|
||
export * from './agent';
|
||
export * from './analysis';
|
||
```
|
||
|
||
- [ ] **Step 3: Run shared type-check**
|
||
|
||
Run: `pnpm --filter @ftb/shared type-check`
|
||
|
||
Expected: command exits `0`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/analysis.ts packages/shared/src/index.ts
|
||
git commit -m "feat(ai-analysis): 添加业务分析共享契约"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Semantic Layer, Metric Catalog, and Plan Processor
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/metric-catalog.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/analysis-plan-processor.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/metric-catalog.spec.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes from Task 1: `AnalysisPlan`, `MetricDefinition`, `MetricId`, `DimensionId`, `AnalysisType`, `TimePolicy`, `DataScope`.
|
||
- Produces:
|
||
- `parseSemanticIntent(question: string): SemanticIntent`
|
||
- `getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null`
|
||
- `normalizeAnalysisPlan(input: AnalysisPlanDraft, now?: Date): AnalysisPlan`
|
||
- `validateAnalysisPlan(plan: AnalysisPlan): void`
|
||
|
||
- [ ] **Step 1: Write failing semantic tests**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts`:
|
||
|
||
```ts
|
||
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||
|
||
describe('analysis semantic layer', () => {
|
||
it('maps busy wording to workload with high confidence', () => {
|
||
expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
|
||
concept: 'workload',
|
||
semanticConfidence: 'high',
|
||
metricId: 'department_workload',
|
||
analysisType: 'ranking',
|
||
});
|
||
});
|
||
|
||
it('maps pressure wording to workload pressure with medium confidence', () => {
|
||
expect(parseSemanticIntent('谁压力最大')).toMatchObject({
|
||
concept: 'work_pressure',
|
||
semanticConfidence: 'medium',
|
||
metricId: 'member_pending_work',
|
||
analysisType: 'ranking',
|
||
});
|
||
});
|
||
|
||
it('keeps project risk as current-state release risk', () => {
|
||
expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
|
||
concept: 'release_risk',
|
||
metricId: 'version_risk_score',
|
||
timePolicy: 'current_state',
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing catalog and processor tests**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/metric-catalog.spec.ts`:
|
||
|
||
```ts
|
||
import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';
|
||
|
||
describe('metric catalog', () => {
|
||
it('defines versioned active metrics used by the MVP templates', () => {
|
||
const metric = getMetricDefinition('version_risk_score');
|
||
|
||
expect(metric).toMatchObject({
|
||
metricId: 'version_risk_score',
|
||
version: 1,
|
||
status: 'active',
|
||
defaultTimePolicy: 'current_state',
|
||
});
|
||
});
|
||
|
||
it('does not expose raw chart renderer contracts', () => {
|
||
const charts = listMetricDefinitions().map((item) => item.defaultChart);
|
||
|
||
expect(charts).toContain('horizontal_bar');
|
||
expect(charts).not.toContain('echarts_option');
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts`:
|
||
|
||
```ts
|
||
import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';
|
||
|
||
describe('analysis plan processor', () => {
|
||
it('normalizes trend plans without a time range to last 30 days', () => {
|
||
const plan = normalizeAnalysisPlan({
|
||
metricId: 'requirement_completion_count',
|
||
analysisType: 'trend',
|
||
dimensions: ['day'],
|
||
scope: { type: 'self', userId: 'm-1' },
|
||
filters: {},
|
||
}, new Date('2026-07-08T12:00:00.000Z'));
|
||
|
||
expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
|
||
expect(plan.timeRange).toEqual({
|
||
start: '2026-06-09T00:00:00.000Z',
|
||
end: '2026-07-08T23:59:59.999Z',
|
||
policy: 'last_30_days',
|
||
});
|
||
});
|
||
|
||
it('keeps current-state risk plans without a time range', () => {
|
||
const plan = normalizeAnalysisPlan({
|
||
metricId: 'version_risk_score',
|
||
analysisType: 'ranking',
|
||
dimensions: ['version'],
|
||
scope: { type: 'managed_projects', projectIds: ['project-1'] },
|
||
filters: {},
|
||
}, new Date('2026-07-08T12:00:00.000Z'));
|
||
|
||
expect(plan.timeRange).toBeUndefined();
|
||
expect(plan.limit).toBe(10);
|
||
});
|
||
|
||
it('rejects unsupported metric dimensions', () => {
|
||
const plan = normalizeAnalysisPlan({
|
||
metricId: 'bug_severity_count',
|
||
analysisType: 'ranking',
|
||
dimensions: ['department'],
|
||
scope: { type: 'self', userId: 'm-1' },
|
||
filters: {},
|
||
});
|
||
|
||
expect(() => validateAnalysisPlan(plan)).toThrow('Unsupported dimension department for metric bug_severity_count');
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog`
|
||
|
||
Expected before implementation: FAIL because files/functions do not exist.
|
||
|
||
- [ ] **Step 3: Implement semantic layer**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts`:
|
||
|
||
```ts
|
||
import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';
|
||
|
||
export type SemanticConcept =
|
||
| 'workload'
|
||
| 'work_pressure'
|
||
| 'release_risk'
|
||
| 'delay'
|
||
| 'delivery_efficiency'
|
||
| 'quality_risk'
|
||
| 'requirement_completion'
|
||
| 'unknown';
|
||
|
||
export interface SemanticIntent {
|
||
concept: SemanticConcept;
|
||
metricId: MetricId;
|
||
analysisType: AnalysisType;
|
||
timePolicy: TimePolicy;
|
||
semanticConfidence: 'high' | 'medium' | 'low';
|
||
}
|
||
|
||
export function parseSemanticIntent(question: string): SemanticIntent {
|
||
const text = question.trim().toLowerCase();
|
||
if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
|
||
return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
|
||
}
|
||
if (/(压力|压着|吃紧)/.test(text)) {
|
||
return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
|
||
}
|
||
if (/(忙|负载|待办|任务最多)/.test(text)) {
|
||
const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text) ? 'department_workload' : 'member_pending_work';
|
||
return intent('workload', metricId, 'ranking', 'current_state', 'high');
|
||
}
|
||
if (/(延期|逾期|超期)/.test(text)) {
|
||
return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
|
||
}
|
||
if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
|
||
return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
|
||
}
|
||
if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
|
||
const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
|
||
const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
|
||
return intent('quality_risk', metricId, analysisType, metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state', 'high');
|
||
}
|
||
if (/(加班|投入|工时)/.test(text)) {
|
||
return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
|
||
}
|
||
return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
|
||
}
|
||
|
||
function intent(
|
||
concept: SemanticConcept,
|
||
metricId: MetricId,
|
||
analysisType: AnalysisType,
|
||
timePolicy: TimePolicy,
|
||
semanticConfidence: 'high' | 'medium' | 'low',
|
||
): SemanticIntent {
|
||
return { concept, metricId, analysisType, timePolicy, semanticConfidence };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement metric catalog**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/metric-catalog.ts` with one active `version: 1` definition for every MVP metric:
|
||
|
||
```ts
|
||
import type { MetricDefinition, MetricId } from '@ftb/shared';
|
||
|
||
export const METRIC_CATALOG: MetricDefinition[] = [
|
||
{
|
||
metricId: 'version_risk_score',
|
||
version: 1,
|
||
name: '版本风险分',
|
||
description: '基于小宝风险摘要的当前版本风险排行。',
|
||
formula: 'xiaobao_risk_summaries.risk_score',
|
||
owner: 'xiaobao',
|
||
supportedDimensions: ['version', 'project', 'product'],
|
||
supportedAnalysisTypes: ['ranking', 'summary'],
|
||
defaultChart: 'horizontal_bar',
|
||
defaultDimension: 'version',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'completion_trend',
|
||
version: 1,
|
||
name: '完成趋势',
|
||
description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
|
||
formula: 'count(completed_at or terminal status updated_at) by day',
|
||
owner: 'analysis',
|
||
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||
supportedAnalysisTypes: ['trend', 'comparison'],
|
||
defaultChart: 'line_area',
|
||
defaultDimension: 'day',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'overdue_item_count',
|
||
version: 1,
|
||
name: '逾期事项数',
|
||
description: '当前超过计划结束时间且未完成的事项数量。',
|
||
formula: 'count(open items where due_at < now)',
|
||
owner: 'analysis',
|
||
supportedDimensions: ['version', 'project', 'member', 'department'],
|
||
supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
|
||
defaultChart: 'horizontal_bar',
|
||
defaultDimension: 'version',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'requirement_status_count',
|
||
version: 1,
|
||
name: '需求状态分布',
|
||
description: '按需求状态统计需求数量。',
|
||
formula: 'count(requirements) by status',
|
||
owner: 'requirement',
|
||
supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
|
||
supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
|
||
defaultChart: 'donut',
|
||
defaultDimension: 'requirement_status',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'requirement_completion_count',
|
||
version: 1,
|
||
name: '需求完成数量',
|
||
description: '按时间统计进入 released 或 closed 的需求数量。',
|
||
formula: "count(requirements where status in ('released','closed')) by time bucket",
|
||
owner: 'requirement',
|
||
supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
|
||
supportedAnalysisTypes: ['trend', 'comparison'],
|
||
defaultChart: 'line_area',
|
||
defaultDimension: 'day',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'requirement_source_count',
|
||
version: 1,
|
||
name: '需求来源/类型占比',
|
||
description: '按需求来源或类型统计需求数量。',
|
||
formula: 'count(requirements) by source_type or type',
|
||
owner: 'requirement',
|
||
supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
|
||
supportedAnalysisTypes: ['composition', 'distribution'],
|
||
defaultChart: 'donut',
|
||
defaultDimension: 'requirement_source',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'department_workload',
|
||
version: 1,
|
||
name: '部门负载',
|
||
description: '按部门统计当前未完成事项数量。',
|
||
formula: 'count(open work items grouped by user.department_id)',
|
||
owner: 'management',
|
||
supportedDimensions: ['department'],
|
||
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||
defaultChart: 'horizontal_bar',
|
||
defaultDimension: 'department',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'member_pending_work',
|
||
version: 1,
|
||
name: '成员待办',
|
||
description: '按成员统计当前未完成事项数量。',
|
||
formula: 'count(open work items grouped by assignee or owner)',
|
||
owner: 'management',
|
||
supportedDimensions: ['member', 'role', 'project', 'version'],
|
||
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||
defaultChart: 'horizontal_bar',
|
||
defaultDimension: 'member',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'member_effort_hours',
|
||
version: 1,
|
||
name: '成员投入工时',
|
||
description: '按成员统计工作活动、工时记录和加班投入。',
|
||
formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
|
||
owner: 'management',
|
||
supportedDimensions: ['member', 'department', 'project', 'version'],
|
||
supportedAnalysisTypes: ['ranking', 'comparison'],
|
||
defaultChart: 'horizontal_bar',
|
||
defaultDimension: 'member',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'bug_severity_count',
|
||
version: 1,
|
||
name: 'Bug 严重度分布',
|
||
description: '按严重度统计未关闭 Bug 数量。',
|
||
formula: "count(bugs where status not in ('closed','rejected')) by severity",
|
||
owner: 'quality',
|
||
supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
|
||
supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
|
||
defaultChart: 'stacked_horizontal_bar',
|
||
defaultDimension: 'bug_severity',
|
||
defaultTimePolicy: 'current_state',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'test_pass_rate',
|
||
version: 1,
|
||
name: '测试通过率',
|
||
description: '按时间统计测试通过用例占已执行用例比例。',
|
||
formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
|
||
owner: 'quality',
|
||
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||
supportedAnalysisTypes: ['trend', 'comparison'],
|
||
defaultChart: 'line_area',
|
||
defaultDimension: 'day',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'overtime_reason_hours',
|
||
version: 1,
|
||
name: '加班原因工时',
|
||
description: '按加班原因统计加班时长。',
|
||
formula: 'sum(overtime_records.hours) by reason',
|
||
owner: 'management',
|
||
supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
|
||
supportedAnalysisTypes: ['composition', 'ranking'],
|
||
defaultChart: 'donut',
|
||
defaultDimension: 'delay_reason',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'delay_rate',
|
||
version: 1,
|
||
name: '延期率',
|
||
description: '按范围统计逾期事项占全部计划事项比例。',
|
||
formula: 'overdue_count / planned_item_count',
|
||
owner: 'analysis',
|
||
supportedDimensions: ['month', 'project', 'version', 'department'],
|
||
supportedAnalysisTypes: ['trend', 'comparison'],
|
||
defaultChart: 'line_area',
|
||
defaultDimension: 'month',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
{
|
||
metricId: 'delay_reason_count',
|
||
version: 1,
|
||
name: '延期原因数量',
|
||
description: '按原因统计延期相关需求变更或加班原因。',
|
||
formula: 'count(requirement.change_reason) + count(overtime.reason)',
|
||
owner: 'analysis',
|
||
supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
|
||
supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
|
||
defaultChart: 'line_area',
|
||
defaultDimension: 'delay_reason',
|
||
defaultTimePolicy: 'last_30_days',
|
||
status: 'active',
|
||
},
|
||
];
|
||
|
||
export function listMetricDefinitions(): MetricDefinition[] {
|
||
return METRIC_CATALOG.slice();
|
||
}
|
||
|
||
export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
|
||
const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
|
||
if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
|
||
return candidates.find((item) => item.status === 'active') ?? null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement plan processor**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/analysis-plan-processor.ts` with these exports:
|
||
|
||
```ts
|
||
import { BadRequestException } from '@nestjs/common';
|
||
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
|
||
import { getMetricDefinition } from './metric-catalog';
|
||
|
||
export interface AnalysisPlanDraft {
|
||
metricId: MetricId;
|
||
metricVersion?: number;
|
||
analysisType: AnalysisType;
|
||
dimensions: DimensionId[];
|
||
scope: DataScope;
|
||
filters?: Record<string, string | number | boolean | string[] | number[]>;
|
||
timeRange?: { start: string; end: string; policy?: TimePolicy };
|
||
limit?: number;
|
||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||
}
|
||
|
||
const MAX_TOP_N = 20;
|
||
const DEFAULT_TOP_N = 10;
|
||
|
||
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
|
||
const metric = getMetricDefinition(input.metricId, input.metricVersion);
|
||
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
|
||
|
||
const analysisType = input.analysisType;
|
||
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
|
||
const timeRange = input.timeRange
|
||
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
|
||
: needsDefaultTime
|
||
? last30Days(now)
|
||
: undefined;
|
||
|
||
const plan: AnalysisPlan = {
|
||
metricRef: { metricId: metric.metricId, version: metric.version },
|
||
analysisType,
|
||
dimensions: input.dimensions.length > 0 ? input.dimensions : metric.defaultDimension ? [metric.defaultDimension] : [],
|
||
filters: input.filters ?? {},
|
||
scope: input.scope,
|
||
...(timeRange ? { timeRange } : {}),
|
||
limit: normalizeLimit(input.limit, analysisType),
|
||
...(input.sort ? { sort: input.sort } : {}),
|
||
};
|
||
validateAnalysisPlan(plan);
|
||
return plan;
|
||
}
|
||
|
||
export function validateAnalysisPlan(plan: AnalysisPlan): void {
|
||
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||
if (!metric || metric.status !== 'active') {
|
||
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
|
||
}
|
||
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
|
||
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
|
||
}
|
||
for (const dimension of plan.dimensions) {
|
||
if (!metric.supportedDimensions.includes(dimension)) {
|
||
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
|
||
}
|
||
}
|
||
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
|
||
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
|
||
}
|
||
if (plan.timeRange) {
|
||
const start = Date.parse(plan.timeRange.start);
|
||
const end = Date.parse(plan.timeRange.end);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
|
||
throw new BadRequestException('Invalid time range');
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
|
||
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
|
||
const value = limit ?? DEFAULT_TOP_N;
|
||
return Math.min(Math.max(1, value), MAX_TOP_N);
|
||
}
|
||
|
||
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
|
||
const end = new Date(now);
|
||
end.setHours(23, 59, 59, 999);
|
||
const start = new Date(end);
|
||
start.setDate(start.getDate() - 29);
|
||
start.setHours(0, 0, 0, 0);
|
||
return {
|
||
start: start.toISOString(),
|
||
end: end.toISOString(),
|
||
policy: 'last_30_days',
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run focused backend tests**
|
||
|
||
Run: `pnpm --filter server test -- analysis-semantic-layer analysis-plan-processor metric-catalog`
|
||
|
||
Expected: PASS for the three new test files.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/modules/ai/analysis/analysis-semantic-layer.ts \
|
||
apps/server/src/modules/ai/analysis/metric-catalog.ts \
|
||
apps/server/src/modules/ai/analysis/analysis-plan-processor.ts \
|
||
apps/server/src/modules/ai/analysis/analysis-semantic-layer.spec.ts \
|
||
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts \
|
||
apps/server/src/modules/ai/analysis/analysis-plan-processor.spec.ts
|
||
git commit -m "feat(ai-analysis): 添加语义层和分析计划处理器"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Permission Scope Resolver
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/modules/ai/analysis/permission-scope-resolver.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DataScope` from Task 1, `RbacService` from `apps/server/src/common/rbac/rbac.service.ts`, PrismaService.
|
||
- Produces:
|
||
- `resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope>`
|
||
- `buildScopedWhere(scope: DataScope): { productId?: any; projectId?: any; versionId?: any }`
|
||
|
||
- [ ] **Step 1: Write failing resolver tests**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts`:
|
||
|
||
```ts
|
||
import { ForbiddenException } from '@nestjs/common';
|
||
import { PermissionScopeResolver } from './permission-scope-resolver';
|
||
|
||
describe('PermissionScopeResolver', () => {
|
||
function makeResolver() {
|
||
const prisma = {
|
||
project: { findFirst: jest.fn() },
|
||
version: { findFirst: jest.fn() },
|
||
projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
|
||
};
|
||
const rbac = {
|
||
assertGlobalPermission: jest.fn(),
|
||
assertProjectRole: jest.fn(),
|
||
};
|
||
return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
|
||
}
|
||
|
||
it('returns system scope for wildcard permissions', async () => {
|
||
const { rbac, resolver } = makeResolver();
|
||
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });
|
||
|
||
await expect(resolver.resolveAnalysisScope({
|
||
actorId: 'm-8',
|
||
permissions: ['*'],
|
||
context: { surface: 'ai_assistant' },
|
||
})).resolves.toEqual({ type: 'system', reason: 'admin' });
|
||
});
|
||
|
||
it('returns managed project scope for management permission', async () => {
|
||
const { prisma, rbac, resolver } = makeResolver();
|
||
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
|
||
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);
|
||
|
||
await expect(resolver.resolveAnalysisScope({
|
||
actorId: 'm-pm',
|
||
permissions: ['management:view'],
|
||
context: { surface: 'ai_assistant' },
|
||
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
|
||
});
|
||
|
||
it('restricts version context to the requested version when the user has page access', async () => {
|
||
const { prisma, rbac, resolver } = makeResolver();
|
||
prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
|
||
rbac.assertProjectRole.mockResolvedValue({ actorId: 'm-dev', projectId: 'project-1', role: 'member', via: 'project_member' });
|
||
|
||
await expect(resolver.resolveAnalysisScope({
|
||
actorId: 'm-dev',
|
||
permissions: [],
|
||
context: { surface: 'version_detail', versionId: 'version-1' },
|
||
})).resolves.toEqual({ type: 'version', versionId: 'version-1' });
|
||
});
|
||
|
||
it('rejects missing actor id', async () => {
|
||
const { resolver } = makeResolver();
|
||
|
||
await expect(resolver.resolveAnalysisScope({
|
||
actorId: '',
|
||
permissions: [],
|
||
context: { surface: 'ai_assistant' },
|
||
})).rejects.toBeInstanceOf(ForbiddenException);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm --filter server test -- permission-scope-resolver`
|
||
|
||
Expected before implementation: FAIL because the resolver file does not exist.
|
||
|
||
- [ ] **Step 2: Implement resolver**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/permission-scope-resolver.ts`:
|
||
|
||
```ts
|
||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||
import type { AnalysisRequest, DataScope } from '@ftb/shared';
|
||
import { RbacService } from '../../../common/rbac/rbac.service';
|
||
import { PrismaService } from '../../../prisma/prisma.service';
|
||
|
||
export interface AnalysisScopeInput {
|
||
actorId?: string;
|
||
permissions: string[];
|
||
context?: AnalysisRequest['context'];
|
||
}
|
||
|
||
@Injectable()
|
||
export class PermissionScopeResolver {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly rbac: RbacService,
|
||
) {}
|
||
|
||
async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
|
||
const actorId = input.actorId?.trim();
|
||
if (!actorId) throw new ForbiddenException('Missing actor scope');
|
||
|
||
const context = input.context;
|
||
if (context?.surface === 'version_detail' && context.versionId) {
|
||
const version = await this.prisma.version.findFirst({
|
||
where: { id: context.versionId },
|
||
select: { id: true, projectId: true },
|
||
});
|
||
if (!version) throw new NotFoundException('Version not found');
|
||
if (version.projectId) {
|
||
await this.rbac.assertProjectRole({
|
||
actorId,
|
||
projectId: version.projectId,
|
||
allowedRoles: ['viewer'],
|
||
permissions: input.permissions,
|
||
});
|
||
}
|
||
return { type: 'version', versionId: version.id };
|
||
}
|
||
|
||
if (context?.surface === 'project_detail' && context.projectId) {
|
||
await this.rbac.assertProjectRole({
|
||
actorId,
|
||
projectId: context.projectId,
|
||
allowedRoles: ['viewer'],
|
||
permissions: input.permissions,
|
||
});
|
||
return { type: 'project', projectId: context.projectId };
|
||
}
|
||
|
||
if (context?.surface === 'product_detail' && context.productId) {
|
||
if (input.permissions.includes('*')) return { type: 'product', productId: context.productId };
|
||
const project = await this.prisma.project.findFirst({
|
||
where: { productId: context.productId, members: { some: { userId: actorId } } },
|
||
select: { id: true },
|
||
});
|
||
if (!project && !input.permissions.includes('product:view')) {
|
||
throw new ForbiddenException('No product analysis scope');
|
||
}
|
||
return { type: 'product', productId: context.productId };
|
||
}
|
||
|
||
if (input.permissions.includes('*')) {
|
||
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
|
||
return { type: 'system', reason: 'admin' };
|
||
}
|
||
|
||
if (input.permissions.includes('management:view')) {
|
||
await this.rbac.assertGlobalPermission({ actorId, permissions: input.permissions, requiredPermissions: ['management:view'] });
|
||
const rows = await this.prisma.projectMember.findMany({
|
||
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
|
||
select: { projectId: true },
|
||
});
|
||
return { type: 'managed_projects', projectIds: Array.from(new Set(rows.map((row) => row.projectId))) };
|
||
}
|
||
|
||
return { type: 'self', userId: actorId };
|
||
}
|
||
}
|
||
|
||
export function buildScopedWhere(scope: DataScope) {
|
||
if (scope.type === 'product') return { productId: scope.productId };
|
||
if (scope.type === 'project') return { projectId: scope.projectId };
|
||
if (scope.type === 'version') return { versionId: scope.versionId };
|
||
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
|
||
return {};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run focused resolver tests**
|
||
|
||
Run: `pnpm --filter server test -- permission-scope-resolver`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/modules/ai/analysis/permission-scope-resolver.ts \
|
||
apps/server/src/modules/ai/analysis/permission-scope-resolver.spec.ts
|
||
git commit -m "feat(ai-analysis): 添加分析权限范围解析"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Metric Engine MVP Queries
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/modules/ai/analysis/metric-engine.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/metric-engine.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: normalized `AnalysisPlan`, `buildScopedWhere(scope)`, Prisma relation models.
|
||
- Produces:
|
||
- `executeMetric(plan: AnalysisPlan, now?: Date): Promise<MetricResult>`
|
||
|
||
- [ ] **Step 1: Write failing Metric Engine tests**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/metric-engine.spec.ts`:
|
||
|
||
```ts
|
||
import { MetricEngine } from './metric-engine';
|
||
|
||
describe('MetricEngine', () => {
|
||
function makeEngine() {
|
||
const prisma = {
|
||
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||
version: { findMany: jest.fn() },
|
||
versionPlan: { findMany: jest.fn() },
|
||
devTask: { findMany: jest.fn() },
|
||
testCase: { findMany: jest.fn() },
|
||
bug: { findMany: jest.fn() },
|
||
requirement: { findMany: jest.fn() },
|
||
taskWorklog: { findMany: jest.fn() },
|
||
overtimeRecord: { findMany: jest.fn() },
|
||
user: { findMany: jest.fn() },
|
||
};
|
||
return { prisma, engine: new MetricEngine(prisma as any) };
|
||
}
|
||
|
||
it('returns version risk ranking from Xiaobao summaries', async () => {
|
||
const { prisma, engine } = makeEngine();
|
||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||
{ versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||
{ versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||
]);
|
||
prisma.version.findMany.mockResolvedValue([
|
||
{ id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
|
||
{ id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
|
||
]);
|
||
|
||
const result = await engine.executeMetric({
|
||
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||
analysisType: 'ranking',
|
||
dimensions: ['version'],
|
||
filters: {},
|
||
scope: { type: 'project', projectId: 'project-1' },
|
||
limit: 10,
|
||
}, new Date('2026-07-08T12:00:00.000Z'));
|
||
|
||
expect(result.rows).toEqual([
|
||
{ versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
|
||
{ versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
|
||
]);
|
||
expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
|
||
});
|
||
|
||
it('groups member pending work across plans, tasks, cases, and bugs', async () => {
|
||
const { prisma, engine } = makeEngine();
|
||
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
|
||
prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
|
||
prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
|
||
prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
|
||
prisma.user.findMany.mockResolvedValue([
|
||
{ id: 'm-1', name: '张三', departmentId: '研发' },
|
||
{ id: 'm-2', name: '李四', departmentId: '测试' },
|
||
]);
|
||
|
||
const result = await engine.executeMetric({
|
||
metricRef: { metricId: 'member_pending_work', version: 1 },
|
||
analysisType: 'ranking',
|
||
dimensions: ['member'],
|
||
filters: {},
|
||
scope: { type: 'version', versionId: 'ver-1' },
|
||
limit: 10,
|
||
});
|
||
|
||
expect(result.rows).toEqual([
|
||
{ memberId: 'm-1', label: '张三', value: 3 },
|
||
{ memberId: 'm-2', label: '李四', value: 1 },
|
||
]);
|
||
});
|
||
|
||
it('returns no-data evidence for empty requirement trends', async () => {
|
||
const { prisma, engine } = makeEngine();
|
||
prisma.requirement.findMany.mockResolvedValue([]);
|
||
|
||
const result = await engine.executeMetric({
|
||
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||
analysisType: 'trend',
|
||
dimensions: ['day'],
|
||
filters: {},
|
||
scope: { type: 'self', userId: 'm-1' },
|
||
timeRange: {
|
||
start: '2026-06-09T00:00:00.000Z',
|
||
end: '2026-07-08T23:59:59.999Z',
|
||
policy: 'last_30_days',
|
||
},
|
||
});
|
||
|
||
expect(result.rows).toEqual([]);
|
||
expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm --filter server test -- metric-engine`
|
||
|
||
Expected before implementation: FAIL because `MetricEngine` does not exist.
|
||
|
||
- [ ] **Step 2: Implement MetricEngine with dispatcher**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/metric-engine.ts` with:
|
||
|
||
```ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import type { AnalysisPlan, EvidenceItem, MetricResult } from '@ftb/shared';
|
||
import { PrismaService } from '../../../prisma/prisma.service';
|
||
import { buildScopedWhere } from './permission-scope-resolver';
|
||
|
||
type OpenWorkItem = {
|
||
id: string;
|
||
versionId: string;
|
||
title: string;
|
||
ownerId?: string | null;
|
||
assigneeId?: string | null;
|
||
};
|
||
|
||
@Injectable()
|
||
export class MetricEngine {
|
||
constructor(private readonly prisma: PrismaService) {}
|
||
|
||
async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
|
||
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
|
||
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
|
||
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
|
||
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
|
||
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
|
||
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
|
||
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
|
||
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
|
||
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
|
||
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
|
||
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
|
||
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
|
||
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
|
||
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
|
||
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
|
||
}
|
||
|
||
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const scopedWhere = buildScopedWhere(plan.scope);
|
||
const versionWhere = 'versionId' in scopedWhere ? { id: scopedWhere.versionId } : scopedWhere;
|
||
const versions = await this.prisma.version.findMany({ where: versionWhere, select: { id: true, name: true, projectId: true, productId: true } });
|
||
const versionById = new Map(versions.map((version: any) => [version.id, version]));
|
||
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||
where: { versionId: { in: versions.map((version: any) => version.id) } },
|
||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||
take: plan.limit ?? 10,
|
||
});
|
||
return {
|
||
metricRef: plan.metricRef,
|
||
analysisType: plan.analysisType,
|
||
columns: [
|
||
{ id: 'label', label: '版本', type: 'string' },
|
||
{ id: 'value', label: '风险分', type: 'number' },
|
||
{ id: 'riskLevel', label: '风险等级', type: 'string' },
|
||
],
|
||
rows: rows.map((row: any) => ({
|
||
versionId: row.versionId,
|
||
label: versionById.get(row.versionId)?.name ?? row.versionId,
|
||
value: row.riskScore,
|
||
riskLevel: row.riskLevel,
|
||
})),
|
||
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
|
||
dataScope: plan.scope,
|
||
generatedAt: now.toISOString(),
|
||
};
|
||
}
|
||
|
||
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where = buildScopedWhere(plan.scope);
|
||
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' } } }),
|
||
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' } } }),
|
||
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] } } }),
|
||
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } } }),
|
||
]);
|
||
const counts = new Map<string, number>();
|
||
for (const item of [...plans.map((p: any) => ({ ...p, assigneeId: p.ownerId })), ...devTasks, ...testCases, ...bugs] as OpenWorkItem[]) {
|
||
const memberId = item.assigneeId ?? item.ownerId;
|
||
if (!memberId) continue;
|
||
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
|
||
}
|
||
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(counts.keys()) } }, select: { id: true, name: true, departmentId: true } });
|
||
const userById = new Map(users.map((user: any) => [user.id, user]));
|
||
const rows = Array.from(counts.entries())
|
||
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
|
||
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
|
||
.slice(0, plan.limit ?? 10);
|
||
return {
|
||
metricRef: plan.metricRef,
|
||
analysisType: plan.analysisType,
|
||
columns: [
|
||
{ id: 'label', label: '成员', type: 'string' },
|
||
{ id: 'value', label: '待办数', type: 'number' },
|
||
],
|
||
rows,
|
||
evidence: [{ label: '未完成事项', value: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), sourceDomain: 'dev_task' }],
|
||
dataScope: plan.scope,
|
||
generatedAt: now.toISOString(),
|
||
};
|
||
}
|
||
|
||
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const memberResult = await this.memberPendingWork({ ...plan, metricRef: { metricId: 'member_pending_work', version: 1 }, dimensions: ['member'] }, now);
|
||
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
|
||
const users = await this.prisma.user.findMany({ where: { id: { in: memberIds } }, select: { id: true, departmentId: true } });
|
||
const departmentByMember = new Map(users.map((user: any) => [user.id, user.departmentId || '未分部门']));
|
||
const counts = new Map<string, number>();
|
||
for (const row of memberResult.rows) {
|
||
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
|
||
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
|
||
}
|
||
return rowsResult(plan, now, '部门', '待办数', Array.from(counts.entries()).map(([label, value]) => ({ label, value })), 'dev_task');
|
||
}
|
||
|
||
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where = buildScopedWhere(plan.scope);
|
||
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||
this.prisma.versionPlan.findMany({ where: { ...where, status: { not: 'completed' }, expectedEndAt: { lt: now } } }),
|
||
this.prisma.devTask.findMany({ where: { ...where, status: { not: 'submitted' }, expectedEndAt: { lt: now } } }),
|
||
this.prisma.testCase.findMany({ where: { ...where, status: { notIn: ['passed', 'failed', 'blocked'] }, plannedEndAt: { lt: now } } }),
|
||
this.prisma.bug.findMany({ where: { ...where, status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } } }),
|
||
]);
|
||
const byVersion = new Map<string, number>();
|
||
for (const item of [...plans, ...devTasks, ...testCases, ...bugs] as any[]) {
|
||
if (!item.versionId) continue;
|
||
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
|
||
}
|
||
return rowsResult(plan, now, '版本', '逾期数', Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })), 'version');
|
||
}
|
||
|
||
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const rows = await this.prisma.requirement.findMany({ where: buildScopedWhere(plan.scope), select: { status: true } });
|
||
return countRows(plan, now, rows.map((row: any) => row.status || 'unknown'), '状态', '数量', 'requirement');
|
||
}
|
||
|
||
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['released', 'closed'] } };
|
||
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
|
||
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } });
|
||
return trendRows(plan, now, rows.map((row: any) => row.updatedAt), '完成需求', 'requirement');
|
||
}
|
||
|
||
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where: any = buildScopedWhere(plan.scope);
|
||
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
|
||
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } });
|
||
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
|
||
return countRows(plan, now, rows.map((row: any) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
|
||
}
|
||
|
||
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where: any = buildScopedWhere(plan.scope);
|
||
if (plan.timeRange) {
|
||
where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
|
||
}
|
||
const [worklogs, overtime] = await Promise.all([
|
||
this.prisma.taskWorklog.findMany({ where, select: { userId: true, hours: true } }),
|
||
this.prisma.overtimeRecord.findMany({ where, select: { userId: true, hours: true } }),
|
||
]);
|
||
const hours = new Map<string, number>();
|
||
for (const row of [...worklogs, ...overtime] as any[]) {
|
||
if (!row.userId) continue;
|
||
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
|
||
}
|
||
const users = await this.prisma.user.findMany({ where: { id: { in: Array.from(hours.keys()) } }, select: { id: true, name: true } });
|
||
const userById = new Map(users.map((user: any) => [user.id, user.name]));
|
||
return rowsResult(plan, now, '成员', '小时', Array.from(hours.entries()).map(([memberId, value]) => ({ memberId, label: userById.get(memberId) ?? memberId, value })), 'task_worklog');
|
||
}
|
||
|
||
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const rows = await this.prisma.bug.findMany({ where: { ...buildScopedWhere(plan.scope), status: { notIn: ['closed', 'rejected'] } }, select: { severity: true } });
|
||
return countRows(plan, now, rows.map((row: any) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
|
||
}
|
||
|
||
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where: any = { ...buildScopedWhere(plan.scope), status: { in: ['passed', 'failed', 'blocked'] } };
|
||
if (plan.timeRange) where.updatedAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
|
||
const rows = await this.prisma.testCase.findMany({ where, select: { status: true, updatedAt: true } });
|
||
const buckets = bucketDates(rows.map((row: any) => row.updatedAt));
|
||
const source = Array.from(buckets.entries()).map(([label]) => {
|
||
const sameDay = rows.filter((row: any) => dayKey(row.updatedAt) === label);
|
||
const passed = sameDay.filter((row: any) => row.status === 'passed').length;
|
||
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
|
||
});
|
||
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
|
||
}
|
||
|
||
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const where: any = buildScopedWhere(plan.scope);
|
||
if (plan.timeRange) where.createdAt = { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) };
|
||
const rows = await this.prisma.overtimeRecord.findMany({ where, select: { reason: true, hours: true } });
|
||
const hours = new Map<string, number>();
|
||
for (const row of rows as any[]) {
|
||
const reason = row.reason || '未填写';
|
||
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
|
||
}
|
||
return rowsResult(plan, now, '原因', '小时', Array.from(hours.entries()).map(([label, value]) => ({ label, value })), 'overtime');
|
||
}
|
||
|
||
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const requirementPlan = { ...plan, metricRef: { metricId: 'requirement_completion_count' as const, version: 1 }, dimensions: ['day' as const] };
|
||
return this.requirementCompletionTrend(requirementPlan, now);
|
||
}
|
||
|
||
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
const overdue = await this.overdueItemCount({ ...plan, metricRef: { metricId: 'overdue_item_count', version: 1 }, dimensions: ['version'], analysisType: 'ranking' }, now);
|
||
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
|
||
return { ...overdue, metricRef: plan.metricRef, rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })) };
|
||
}
|
||
|
||
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||
return this.overtimeReasonHours({ ...plan, metricRef: { metricId: 'overtime_reason_hours', version: 1 }, dimensions: ['delay_reason'] }, now);
|
||
}
|
||
}
|
||
|
||
function rowsResult(plan: AnalysisPlan, now: Date, labelName: string, valueName: string, rows: Array<Record<string, string | number | null>>, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
|
||
const sorted = rows.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0)).slice(0, plan.limit ?? rows.length);
|
||
return {
|
||
metricRef: plan.metricRef,
|
||
analysisType: plan.analysisType,
|
||
columns: [
|
||
{ id: 'label', label: labelName, type: 'string' },
|
||
{ id: 'value', label: valueName, type: 'number' },
|
||
],
|
||
rows: sorted,
|
||
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
|
||
dataScope: plan.scope,
|
||
generatedAt: now.toISOString(),
|
||
};
|
||
}
|
||
|
||
function countRows(plan: AnalysisPlan, now: Date, labels: string[], labelName: string, valueName: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
|
||
const counts = new Map<string, number>();
|
||
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
||
return rowsResult(plan, now, labelName, valueName, Array.from(counts.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
|
||
}
|
||
|
||
function trendRows(plan: AnalysisPlan, now: Date, dates: Date[], valueLabel: string, sourceDomain: EvidenceItem['sourceDomain']): MetricResult {
|
||
const buckets = bucketDates(dates);
|
||
return rowsResult(plan, now, '日期', valueLabel, Array.from(buckets.entries()).map(([label, value]) => ({ label, value })), sourceDomain);
|
||
}
|
||
|
||
function bucketDates(dates: Date[]) {
|
||
const buckets = new Map<string, number>();
|
||
for (const date of dates) {
|
||
const key = dayKey(date);
|
||
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||
}
|
||
return buckets;
|
||
}
|
||
|
||
function dayKey(value: Date | string) {
|
||
return new Date(value).toISOString().slice(0, 10);
|
||
}
|
||
|
||
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
|
||
return {
|
||
metricRef: plan.metricRef,
|
||
analysisType: plan.analysisType,
|
||
columns: [],
|
||
rows: [],
|
||
evidence,
|
||
dataScope: plan.scope,
|
||
generatedAt: now.toISOString(),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run focused engine tests**
|
||
|
||
Run: `pnpm --filter server test -- metric-engine`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/modules/ai/analysis/metric-engine.ts \
|
||
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
|
||
git commit -m "feat(ai-analysis): 实现业务分析指标引擎"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Response Builders, AI Planning, and `/ai/analysis`
|
||
|
||
**Files:**
|
||
- Create: `apps/server/src/modules/ai/dto/analysis.dto.ts`
|
||
- Create: `apps/server/src/modules/ai/prompts/analysis-plan.ts`
|
||
- Create: `apps/server/src/modules/ai/prompts/analysis-report.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/analysis-strategy.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/chart-spec-builder.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/insight-engine.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/report-builder.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/follow-up-builder.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/business-analysis.service.ts`
|
||
- Create: `apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts`
|
||
- Modify: `apps/server/src/modules/ai/ai.controller.ts`
|
||
- Modify: `apps/server/src/modules/ai/ai.module.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 1-4.
|
||
- Produces:
|
||
- `POST /api/v1/ai/analysis`
|
||
- `BusinessAnalysisService.analyze(req, user, permissions)`
|
||
- `buildUnifiedChartSpec(result, metric)`
|
||
- `buildInsightCard(result, semanticConfidence)`
|
||
- `buildAnalysisReport(result, insight, metric)`
|
||
- `buildFollowUps(result, plan)`
|
||
|
||
- [ ] **Step 1: Write failing orchestration tests**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/business-analysis.service.spec.ts`:
|
||
|
||
```ts
|
||
import { BusinessAnalysisService } from './business-analysis.service';
|
||
|
||
describe('BusinessAnalysisService', () => {
|
||
function makeService() {
|
||
const scopeResolver = { resolveAnalysisScope: jest.fn().mockResolvedValue({ type: 'version', versionId: 'ver-1' }) };
|
||
const metricEngine = {
|
||
executeMetric: jest.fn().mockResolvedValue({
|
||
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||
analysisType: 'ranking',
|
||
columns: [{ id: 'label', label: '版本', type: 'string' }],
|
||
rows: [{ label: 'V1', value: 88 }],
|
||
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||
dataScope: { type: 'version', versionId: 'ver-1' },
|
||
generatedAt: '2026-07-08T12:00:00.000Z',
|
||
}),
|
||
};
|
||
const reportBuilder = { build: jest.fn().mockResolvedValue({
|
||
summary: 'V1 风险较高。',
|
||
keyFindings: ['风险分 88。'],
|
||
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||
suggestions: ['优先处理阻塞和严重 Bug。'],
|
||
dataScope: {
|
||
timeDescription: '当前状态',
|
||
permissionDescription: '当前版本',
|
||
metricFormulaDescription: '小宝风险分',
|
||
generatedAt: '2026-07-08T12:00:00.000Z',
|
||
},
|
||
}) };
|
||
const service = new BusinessAnalysisService(scopeResolver as any, metricEngine as any, reportBuilder as any);
|
||
return { service, scopeResolver, metricEngine, reportBuilder };
|
||
}
|
||
|
||
it('returns insight, chart, report, evidence, and follow-ups', async () => {
|
||
const { service } = makeService();
|
||
|
||
const result = await service.analyze({
|
||
question: '这个版本风险怎么样',
|
||
context: { surface: 'version_detail', versionId: 'ver-1' },
|
||
}, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z'));
|
||
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok) {
|
||
expect(result.chart.kind).toBe('horizontal_bar');
|
||
expect(result.insight.summary).toContain('V1');
|
||
expect(result.report.summary).toContain('风险');
|
||
expect(result.followUps.some((item) => item.type === 'question')).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('returns NO_DATA when metric result has no rows', async () => {
|
||
const { service, metricEngine } = makeService();
|
||
metricEngine.executeMetric.mockResolvedValueOnce({
|
||
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||
analysisType: 'trend',
|
||
columns: [],
|
||
rows: [],
|
||
evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
|
||
dataScope: { type: 'self', userId: 'm-1' },
|
||
generatedAt: '2026-07-08T12:00:00.000Z',
|
||
});
|
||
|
||
await expect(service.analyze({ question: '需求完成趋势' }, { id: 'm-1', permissions: [] }, new Date('2026-07-08T12:00:00.000Z')))
|
||
.resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm --filter server test -- business-analysis.service`
|
||
|
||
Expected before implementation: FAIL because service/builders do not exist.
|
||
|
||
- [ ] **Step 2: Add DTO and controller endpoint**
|
||
|
||
Create `apps/server/src/modules/ai/dto/analysis.dto.ts`:
|
||
|
||
```ts
|
||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||
import { Type } from 'class-transformer';
|
||
|
||
class AnalysisContextDto {
|
||
@IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
|
||
surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
productId?: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
projectId?: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
versionId?: string;
|
||
}
|
||
|
||
export class AnalysisDto {
|
||
@IsString()
|
||
question!: string;
|
||
|
||
@IsOptional()
|
||
@ValidateNested()
|
||
@Type(() => AnalysisContextDto)
|
||
context?: AnalysisContextDto;
|
||
|
||
@IsOptional()
|
||
@IsArray()
|
||
@IsString({ each: true })
|
||
permissions?: string[];
|
||
}
|
||
```
|
||
|
||
Modify `apps/server/src/modules/ai/ai.controller.ts`:
|
||
|
||
```ts
|
||
import { Body, Controller, Post } from '@nestjs/common';
|
||
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
||
import { AiService } from './ai.service';
|
||
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||
import { AnalysisDto } from './dto/analysis.dto';
|
||
import { DecomposeDto } from './dto/decompose.dto';
|
||
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
||
import type {
|
||
AgentDecomposeResponse,
|
||
AgentDecomposeError,
|
||
AgentRiskInterpretResponse,
|
||
AgentRiskInterpretError,
|
||
AnalysisResponse,
|
||
} from '@ftb/shared';
|
||
import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.service';
|
||
|
||
@Controller('ai')
|
||
export class AiController {
|
||
constructor(
|
||
private readonly aiService: AiService,
|
||
private readonly businessAnalysisService: BusinessAnalysisService,
|
||
) {}
|
||
|
||
@Post('decompose')
|
||
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
||
return this.aiService.decompose(dto);
|
||
}
|
||
|
||
@Post('risk-interpret')
|
||
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||
return this.aiService.interpretRisk(dto);
|
||
}
|
||
|
||
@Post('analysis')
|
||
async analyze(
|
||
@Body() dto: AnalysisDto,
|
||
@CurrentUser() user: ResolvedCurrentUser | null,
|
||
): Promise<AnalysisResponse> {
|
||
return this.businessAnalysisService.analyze(dto, {
|
||
id: user?.id ?? '',
|
||
permissions: dto.permissions ?? [],
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
Security boundary: `dto.permissions` is only the caller's current role permission assertion, matching the existing management/governance API pattern. It never widens data by itself. `PermissionScopeResolver` still verifies project membership through `RbacService.assertProjectRole`, verifies wildcard/global access through `RbacService.assertGlobalPermission`, and returns the narrowest `DataScope` for the request context.
|
||
|
||
- [ ] **Step 3: Add AI Planning prompts and deterministic builders**
|
||
|
||
Create `apps/server/src/modules/ai/prompts/analysis-plan.ts`:
|
||
|
||
```ts
|
||
export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';
|
||
|
||
export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
|
||
你是 FTB 项目管理系统的业务分析计划助手。
|
||
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL,不能绕过权限。
|
||
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
|
||
当问题无法映射到已暴露能力时,返回 clarificationOptions,不要编造指标。
|
||
`.trim();
|
||
|
||
export const ANALYSIS_PLAN_TOOL_DESCRIPTION = 'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';
|
||
|
||
export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
required: ['metricId', 'analysisType', 'dimensions', 'filters'],
|
||
properties: {
|
||
metricId: {
|
||
type: 'string',
|
||
enum: [
|
||
'version_risk_score',
|
||
'completion_trend',
|
||
'overdue_item_count',
|
||
'requirement_status_count',
|
||
'requirement_completion_count',
|
||
'requirement_source_count',
|
||
'department_workload',
|
||
'member_pending_work',
|
||
'member_effort_hours',
|
||
'bug_severity_count',
|
||
'test_pass_rate',
|
||
'overtime_reason_hours',
|
||
'delay_rate',
|
||
'delay_reason_count',
|
||
],
|
||
},
|
||
metricVersion: { type: 'number' },
|
||
analysisType: {
|
||
type: 'string',
|
||
enum: ['ranking', 'trend', 'comparison', 'distribution', 'composition', 'correlation', 'breakdown', 'summary'],
|
||
},
|
||
dimensions: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'string',
|
||
enum: [
|
||
'product',
|
||
'project',
|
||
'version',
|
||
'requirement_status',
|
||
'requirement_type',
|
||
'requirement_source',
|
||
'department',
|
||
'member',
|
||
'role',
|
||
'month',
|
||
'week',
|
||
'day',
|
||
'bug_severity',
|
||
'bug_status',
|
||
'test_status',
|
||
'delay_reason',
|
||
],
|
||
},
|
||
},
|
||
filters: { type: 'object', additionalProperties: true },
|
||
timeRange: {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
required: ['start', 'end'],
|
||
properties: {
|
||
start: { type: 'string' },
|
||
end: { type: 'string' },
|
||
policy: { type: 'string', enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'] },
|
||
},
|
||
},
|
||
limit: { type: 'number', minimum: 1, maximum: 20 },
|
||
sort: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
required: ['field', 'direction'],
|
||
properties: {
|
||
field: { type: 'string' },
|
||
direction: { type: 'string', enum: ['asc', 'desc'] },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
} as const;
|
||
```
|
||
|
||
Create `apps/server/src/modules/ai/prompts/analysis-report.ts`:
|
||
|
||
```ts
|
||
export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';
|
||
|
||
export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
|
||
你是 FTB 项目管理系统的业务分析报告助手。
|
||
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
|
||
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
|
||
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
|
||
`.trim();
|
||
|
||
export const ANALYSIS_REPORT_TOOL_DESCRIPTION = 'Write a structured business analysis report from deterministic metric results without inventing facts.';
|
||
|
||
export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
required: ['summary', 'keyFindings', 'suggestions'],
|
||
properties: {
|
||
summary: { type: 'string' },
|
||
keyFindings: { type: 'array', items: { type: 'string' } },
|
||
suggestions: { type: 'array', items: { type: 'string' } },
|
||
},
|
||
} as const;
|
||
```
|
||
|
||
Create these builder files with deterministic outputs first:
|
||
|
||
`apps/server/src/modules/ai/analysis/chart-spec-builder.ts`:
|
||
|
||
```ts
|
||
import type { MetricDefinition, MetricResult, UnifiedChartSpec } from '@ftb/shared';
|
||
|
||
export function buildUnifiedChartSpec(result: MetricResult, metric: MetricDefinition): UnifiedChartSpec {
|
||
const labelField = result.columns.find((column) => column.type === 'string')?.id ?? 'label';
|
||
const valueField = result.columns.find((column) => column.type === 'number' || column.type === 'percent')?.id ?? 'value';
|
||
return {
|
||
kind: metric.defaultChart,
|
||
title: metric.name,
|
||
subtitle: metric.description,
|
||
dataset: {
|
||
source: result.rows,
|
||
label: labelField,
|
||
value: valueField,
|
||
x: metric.defaultChart === 'line_area' ? labelField : undefined,
|
||
y: metric.defaultChart === 'line_area' ? valueField : undefined,
|
||
},
|
||
encoding: {
|
||
x: metric.defaultChart === 'line_area' ? { field: labelField, label: result.columns.find((column) => column.id === labelField)?.label ?? labelField } : undefined,
|
||
y: metric.defaultChart === 'line_area' ? { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField } : undefined,
|
||
value: { field: valueField, label: result.columns.find((column) => column.id === valueField)?.label ?? valueField },
|
||
color: { mode: result.metricRef.metricId === 'version_risk_score' ? 'risk' : 'single' },
|
||
},
|
||
annotations: [],
|
||
stylePreset: 'apple_vision_light',
|
||
};
|
||
}
|
||
```
|
||
|
||
`apps/server/src/modules/ai/analysis/insight-engine.ts`:
|
||
|
||
```ts
|
||
import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||
|
||
export function buildInsightCard(
|
||
result: MetricResult,
|
||
metric: MetricDefinition,
|
||
semanticConfidence: InsightCard['semanticConfidence'],
|
||
): InsightCard {
|
||
const top = result.rows[0];
|
||
const value = top?.value ?? result.totals?.value ?? 0;
|
||
const label = String(top?.label ?? metric.name);
|
||
const dataConfidence: InsightCard['dataConfidence'] = result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
|
||
return {
|
||
summary: result.rows.length === 0 ? `${metric.name}暂无可分析数据。` : `${label}在${metric.name}中最突出。`,
|
||
primaryValue: { label: metric.name, value },
|
||
semanticConfidence,
|
||
dataConfidence,
|
||
};
|
||
}
|
||
```
|
||
|
||
`apps/server/src/modules/ai/analysis/report-builder.ts`:
|
||
|
||
```ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import type { AnalysisReport, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||
|
||
@Injectable()
|
||
export class AnalysisReportBuilder {
|
||
async build(result: MetricResult, insight: InsightCard, metric: MetricDefinition): Promise<AnalysisReport> {
|
||
return {
|
||
summary: insight.summary,
|
||
keyFindings: result.rows.slice(0, 4).map((row) => `${row.label ?? '项目'}:${row.value ?? 0}`),
|
||
evidence: result.evidence,
|
||
suggestions: result.rows.length > 0 ? ['优先查看排名靠前的对象,并进入明细确认原因。'] : ['调整时间范围或切换分析维度。'],
|
||
dataScope: {
|
||
timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态',
|
||
permissionDescription: describeScope(result.dataScope),
|
||
metricFormulaDescription: metric.formula,
|
||
generatedAt: result.generatedAt,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
function resultHasTime(result: MetricResult): boolean {
|
||
return result.columns.some((column) => column.type === 'date');
|
||
}
|
||
|
||
function describeScope(scope: MetricResult['dataScope']) {
|
||
if (scope.type === 'system') return '系统管理范围';
|
||
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
|
||
if (scope.type === 'self') return '与当前用户相关的数据';
|
||
if (scope.type === 'product') return `产品范围:${scope.productId}`;
|
||
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
|
||
return `版本范围:${scope.versionId}`;
|
||
}
|
||
```
|
||
|
||
`apps/server/src/modules/ai/analysis/follow-up-builder.ts`:
|
||
|
||
```ts
|
||
import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';
|
||
|
||
export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
|
||
const top = result.rows[0];
|
||
const baseQuestion = top?.label ? `为什么${top.label}最高?` : '换一个维度继续分析';
|
||
return [
|
||
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
|
||
{ type: 'drilldown', label: '查看明细', target: plan.dimensions[0] ?? 'analysis', filters: plan.filters },
|
||
{ type: 'export', label: '导出报告', format: 'pdf' },
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement strategy and service**
|
||
|
||
Create `apps/server/src/modules/ai/analysis/analysis-strategy.ts`:
|
||
|
||
```ts
|
||
import type { AnalysisRequest, AnalysisPlan } from '@ftb/shared';
|
||
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||
import { normalizeAnalysisPlan } from './analysis-plan-processor';
|
||
|
||
export function createAnalysisPlanFromQuestion(
|
||
request: AnalysisRequest,
|
||
scope: AnalysisPlan['scope'],
|
||
now = new Date(),
|
||
) {
|
||
const semantic = parseSemanticIntent(request.question);
|
||
const dimensions = inferDimensions(request.question, semantic.metricId);
|
||
const plan = normalizeAnalysisPlan({
|
||
metricId: semantic.metricId,
|
||
analysisType: semantic.analysisType,
|
||
dimensions,
|
||
scope,
|
||
filters: {},
|
||
}, now);
|
||
return { semantic, plan };
|
||
}
|
||
|
||
function inferDimensions(question: string, metricId: AnalysisPlan['metricRef']['metricId']): AnalysisPlan['dimensions'] {
|
||
if (/部门/.test(question)) return ['department'];
|
||
if (/成员|谁|负责人/.test(question)) return ['member'];
|
||
if (/产品/.test(question)) return ['product'];
|
||
if (/项目/.test(question)) return ['project'];
|
||
if (/月|月份/.test(question)) return ['month'];
|
||
if (/周/.test(question)) return ['week'];
|
||
if (metricId === 'version_risk_score') return ['version'];
|
||
if (metricId === 'bug_severity_count') return ['bug_severity'];
|
||
if (metricId === 'requirement_status_count') return ['requirement_status'];
|
||
if (metricId === 'requirement_source_count') return ['requirement_source'];
|
||
return ['version'];
|
||
}
|
||
```
|
||
|
||
Create `apps/server/src/modules/ai/analysis/business-analysis.service.ts`:
|
||
|
||
```ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||
import { getMetricDefinition } from './metric-catalog';
|
||
import { PermissionScopeResolver } from './permission-scope-resolver';
|
||
import { MetricEngine } from './metric-engine';
|
||
import { AnalysisReportBuilder } from './report-builder';
|
||
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
|
||
import { buildUnifiedChartSpec } from './chart-spec-builder';
|
||
import { buildInsightCard } from './insight-engine';
|
||
import { buildFollowUps } from './follow-up-builder';
|
||
|
||
export interface AnalysisActor {
|
||
id: string;
|
||
permissions: string[];
|
||
}
|
||
|
||
@Injectable()
|
||
export class BusinessAnalysisService {
|
||
constructor(
|
||
private readonly scopeResolver: PermissionScopeResolver,
|
||
private readonly metricEngine: MetricEngine,
|
||
private readonly reportBuilder: AnalysisReportBuilder,
|
||
) {}
|
||
|
||
async analyze(request: AnalysisRequest, actor: AnalysisActor, now = new Date()): Promise<AnalysisResponse> {
|
||
const question = request.question.trim();
|
||
if (!question) {
|
||
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
|
||
}
|
||
const scope = await this.scopeResolver.resolveAnalysisScope({
|
||
actorId: actor.id,
|
||
permissions: actor.permissions,
|
||
context: request.context,
|
||
});
|
||
const { semantic, plan } = createAnalysisPlanFromQuestion(request, scope, now);
|
||
if (semantic.semanticConfidence === 'low') {
|
||
return {
|
||
ok: false,
|
||
code: 'AMBIGUOUS_INTENT',
|
||
message: '这个问题有多种理解,请选择一个分析方向。',
|
||
clarificationOptions: [
|
||
{ label: '成员负载', prompt: '分析成员待办排行' },
|
||
{ label: '版本风险', prompt: '分析版本风险排行' },
|
||
],
|
||
dataScope: scope,
|
||
};
|
||
}
|
||
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||
if (!metric) return { ok: false, code: 'UNSUPPORTED_ANALYSIS', message: '当前指标不在分析目录中。', dataScope: scope };
|
||
const metricResult = await this.metricEngine.executeMetric(plan, now);
|
||
if (metricResult.rows.length === 0) {
|
||
return {
|
||
ok: false,
|
||
code: 'NO_DATA',
|
||
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
|
||
dataScope: scope,
|
||
};
|
||
}
|
||
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
|
||
const chart = buildUnifiedChartSpec(metricResult, metric);
|
||
const report = await this.reportBuilder.build(metricResult, insight, metric);
|
||
const followUps = buildFollowUps(metricResult, plan);
|
||
return { ok: true, plan, metricResult, insight, chart, report, followUps };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Wire module providers**
|
||
|
||
Modify `apps/server/src/modules/ai/ai.module.ts`:
|
||
|
||
```ts
|
||
import { Module } from '@nestjs/common';
|
||
import { CommonDomainModule } from '../../common/common-domain.module';
|
||
import { AiController } from './ai.controller';
|
||
import { AiService } from './ai.service';
|
||
import { AiGatewayService } from './ai-gateway.service';
|
||
import { ConfigModule } from '../config/config.module';
|
||
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||
import { MetricEngine } from './analysis/metric-engine';
|
||
import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
|
||
import { AnalysisReportBuilder } from './analysis/report-builder';
|
||
|
||
@Module({
|
||
imports: [ConfigModule, CommonDomainModule],
|
||
controllers: [AiController],
|
||
providers: [
|
||
AiService,
|
||
AiGatewayService,
|
||
BusinessAnalysisService,
|
||
PermissionScopeResolver,
|
||
MetricEngine,
|
||
AnalysisReportBuilder,
|
||
],
|
||
exports: [AiService],
|
||
})
|
||
export class AiModule {}
|
||
```
|
||
|
||
- [ ] **Step 6: Run backend focused tests**
|
||
|
||
Run: `pnpm --filter server test -- business-analysis.service ai.service`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Run server type-check**
|
||
|
||
Run: `pnpm --filter server type-check`
|
||
|
||
Expected: command exits `0`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/server/src/modules/ai
|
||
git commit -m "feat(ai-analysis): 接入业务分析接口"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Frontend Analysis API and ECharts Renderer
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/package.json`
|
||
- Modify: `pnpm-lock.yaml`
|
||
- Create: `apps/web/lib/analysis-api.ts`
|
||
- Create: `apps/web/lib/analysis-api.test.ts`
|
||
- Create: `apps/web/lib/analysis-chart-renderer.ts`
|
||
- Create: `apps/web/lib/analysis-chart-renderer.test.ts`
|
||
- Create: `apps/web/components/analysis/AnalysisChart.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AnalysisRequest`, `AnalysisResponse`, `UnifiedChartSpec` from `@ftb/shared`.
|
||
- Produces:
|
||
- `requestAnalysis(request: AnalysisRequest, permissions?: string[]): Promise<AnalysisResponse>`
|
||
- `toEChartsOption(spec: UnifiedChartSpec): EChartsOption`
|
||
- `<AnalysisChart spec={chart} />`
|
||
|
||
- [ ] **Step 1: Add ECharts dependencies**
|
||
|
||
Run: `pnpm add --filter web echarts echarts-for-react`
|
||
|
||
Expected: `apps/web/package.json` includes `echarts` and `echarts-for-react`; `pnpm-lock.yaml` updates.
|
||
|
||
- [ ] **Step 2: Write failing renderer tests**
|
||
|
||
Create `apps/web/lib/analysis-chart-renderer.test.ts`:
|
||
|
||
```ts
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { toEChartsOption } from './analysis-chart-renderer';
|
||
import type { UnifiedChartSpec } from '@ftb/shared';
|
||
|
||
test('toEChartsOption renders line_area with smooth line and area gradient', () => {
|
||
const spec: UnifiedChartSpec = {
|
||
kind: 'line_area',
|
||
title: '需求完成趋势',
|
||
dataset: { source: [{ label: '2026-07-01', value: 3 }], x: 'label', y: 'value' },
|
||
encoding: {
|
||
x: { field: 'label', label: '日期' },
|
||
y: { field: 'value', label: '完成数' },
|
||
value: { field: 'value', label: '完成数' },
|
||
color: { mode: 'single' },
|
||
},
|
||
annotations: [{ type: 'peak', label: '峰值', field: 'value', value: 3 }],
|
||
stylePreset: 'apple_vision_light',
|
||
};
|
||
|
||
const option: any = toEChartsOption(spec);
|
||
|
||
assert.equal(option.series[0].type, 'line');
|
||
assert.equal(option.series[0].smooth, true);
|
||
assert.ok(option.series[0].areaStyle);
|
||
assert.equal(option.xAxis.show, true);
|
||
assert.equal(option.yAxis.splitLine.show, false);
|
||
});
|
||
|
||
test('toEChartsOption renders horizontal_bar with rounded bars and single color', () => {
|
||
const option: any = toEChartsOption({
|
||
kind: 'horizontal_bar',
|
||
title: '部门负载',
|
||
dataset: { source: [{ label: '研发', value: 8 }], label: 'label', value: 'value' },
|
||
encoding: {
|
||
value: { field: 'value', label: '待办数' },
|
||
color: { mode: 'single' },
|
||
},
|
||
stylePreset: 'apple_vision_light',
|
||
});
|
||
|
||
assert.equal(option.series[0].type, 'bar');
|
||
assert.deepEqual(option.series[0].itemStyle.borderRadius, [0, 8, 8, 0]);
|
||
assert.equal(option.color.length, 1);
|
||
});
|
||
```
|
||
|
||
Create `apps/web/lib/analysis-api.test.ts`:
|
||
|
||
```ts
|
||
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { requestAnalysis } from './analysis-api';
|
||
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
|
||
|
||
test('requestAnalysis posts to /ai/analysis', async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
const calls: string[] = [];
|
||
const apiBase = resolveApiBase();
|
||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||
calls.push(String(input));
|
||
return new Response(JSON.stringify(calls.length === 1 ? {} : { ok: false, code: 'NO_DATA', message: 'no rows' }), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}) as typeof fetch;
|
||
|
||
try {
|
||
__resetApiAvailabilityForTests();
|
||
const result = await requestAnalysis({ question: '哪个部门最忙', context: { surface: 'ai_assistant' } }, ['management:view']);
|
||
assert.equal(result.ok, false);
|
||
assert.deepEqual(calls, [`${apiBase}/config/ai`, `${apiBase}/ai/analysis`]);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
```
|
||
|
||
Run: `pnpm --filter web test -- analysis-chart-renderer analysis-api`
|
||
|
||
Expected before implementation: FAIL because files/functions do not exist.
|
||
|
||
- [ ] **Step 3: Implement analysis API client**
|
||
|
||
Create `apps/web/lib/analysis-api.ts`:
|
||
|
||
```ts
|
||
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||
import { api } from './api';
|
||
|
||
export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise<AnalysisResponse> {
|
||
return api.post<AnalysisResponse>('/ai/analysis', { ...request, permissions });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement ChartSpec renderer**
|
||
|
||
Create `apps/web/lib/analysis-chart-renderer.ts`:
|
||
|
||
```ts
|
||
import type { UnifiedChartSpec } from '@ftb/shared';
|
||
import type { EChartsOption } from 'echarts';
|
||
|
||
const ACCENT = '#0f172a';
|
||
const MUTED = '#94a3b8';
|
||
const RISK = '#f97316';
|
||
|
||
export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
|
||
if (spec.kind === 'line_area') return lineAreaOption(spec);
|
||
if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
|
||
if (spec.kind === 'donut') return donutOption(spec);
|
||
return numberCardFallbackOption(spec);
|
||
}
|
||
|
||
function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
|
||
const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
|
||
const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
|
||
return {
|
||
color: [ACCENT],
|
||
grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
|
||
tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
|
||
xAxis: {
|
||
type: 'category',
|
||
show: true,
|
||
boundaryGap: false,
|
||
axisTick: { show: false },
|
||
axisLine: { show: false },
|
||
axisLabel: { color: MUTED, fontSize: 11 },
|
||
data: spec.dataset.source.map((row) => row[xField]),
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
show: true,
|
||
axisTick: { show: false },
|
||
axisLine: { show: false },
|
||
axisLabel: { show: false },
|
||
splitLine: { show: false },
|
||
},
|
||
series: [{
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 7,
|
||
data: spec.dataset.source.map((row) => row[yField]),
|
||
lineStyle: { width: 3 },
|
||
areaStyle: { opacity: 0.14 },
|
||
markPoint: buildMarkPoints(spec),
|
||
}],
|
||
};
|
||
}
|
||
|
||
function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
|
||
const labelField = spec.dataset.label ?? 'label';
|
||
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||
const rows = spec.dataset.source.slice().reverse();
|
||
return {
|
||
color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
|
||
grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
|
||
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||
xAxis: { type: 'value', show: false },
|
||
yAxis: {
|
||
type: 'category',
|
||
axisTick: { show: false },
|
||
axisLine: { show: false },
|
||
axisLabel: { color: '#334155', fontSize: 12 },
|
||
data: rows.map((row) => row[labelField]),
|
||
},
|
||
series: [{
|
||
type: 'bar',
|
||
data: rows.map((row) => row[valueField]),
|
||
barWidth: 12,
|
||
itemStyle: { borderRadius: [0, 8, 8, 0] },
|
||
label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
|
||
animationDuration: 520,
|
||
}],
|
||
};
|
||
}
|
||
|
||
function donutOption(spec: UnifiedChartSpec): EChartsOption {
|
||
const labelField = spec.dataset.label ?? 'label';
|
||
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||
return {
|
||
color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
|
||
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['62%', '82%'],
|
||
avoidLabelOverlap: true,
|
||
label: { color: '#334155', fontSize: 11 },
|
||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||
data: spec.dataset.source.map((row) => ({ name: row[labelField], value: row[valueField] })),
|
||
}],
|
||
};
|
||
}
|
||
|
||
function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
|
||
return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
|
||
}
|
||
|
||
function buildMarkPoints(spec: UnifiedChartSpec) {
|
||
if (!spec.annotations?.length) return undefined;
|
||
return {
|
||
symbolSize: 42,
|
||
label: { fontSize: 10 },
|
||
data: spec.annotations.map((item) => ({ type: item.type === 'peak' ? 'max' : undefined, name: item.label, value: item.value })),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement AnalysisChart component**
|
||
|
||
Create `apps/web/components/analysis/AnalysisChart.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import dynamic from 'next/dynamic';
|
||
import type { UnifiedChartSpec } from '@ftb/shared';
|
||
import { toEChartsOption } from '@/lib/analysis-chart-renderer';
|
||
|
||
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||
|
||
export function AnalysisChart({ spec }: { spec: UnifiedChartSpec }) {
|
||
return (
|
||
<div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||
<div className="mb-3">
|
||
<h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
|
||
{spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
|
||
</div>
|
||
<ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run frontend focused tests**
|
||
|
||
Run: `pnpm --filter web test -- analysis-chart-renderer analysis-api`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/web/package.json pnpm-lock.yaml \
|
||
apps/web/lib/analysis-api.ts apps/web/lib/analysis-api.test.ts \
|
||
apps/web/lib/analysis-chart-renderer.ts apps/web/lib/analysis-chart-renderer.test.ts \
|
||
apps/web/components/analysis/AnalysisChart.tsx
|
||
git commit -m "feat(ai-analysis): 添加图表渲染和分析API客户端"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Analysis Result Components and AI Assistant Conversation
|
||
|
||
**Files:**
|
||
- Create: `apps/web/components/analysis/InsightCard.tsx`
|
||
- Create: `apps/web/components/analysis/AnalysisReport.tsx`
|
||
- Create: `apps/web/components/analysis/EvidenceList.tsx`
|
||
- Create: `apps/web/components/analysis/FollowUpActions.tsx`
|
||
- Create: `apps/web/components/analysis/AnalysisResultBlock.tsx`
|
||
- Modify: `apps/web/app/wenfan-xiaobao/page.tsx`
|
||
- Test: existing `apps/web/lib/wenfan-xiaobao-ui.test.ts` plus `pnpm --filter web type-check`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AnalysisResponse` from Task 1, `requestAnalysis` and `AnalysisChart` from Task 6.
|
||
- Produces: AI Assistant messages of type `analysis`, `analysis_error`, and existing help fallback.
|
||
|
||
- [ ] **Step 1: Write component files**
|
||
|
||
Create `apps/web/components/analysis/InsightCard.tsx`:
|
||
|
||
```tsx
|
||
import type { InsightCard as InsightCardData } from '@ftb/shared';
|
||
|
||
export function InsightCard({ insight }: { insight: InsightCardData }) {
|
||
return (
|
||
<section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||
{insight.primaryValue && (
|
||
<div className="mb-3">
|
||
<div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">{insight.primaryValue.value}{insight.primaryValue.unit ?? ''}</div>
|
||
<div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
|
||
</div>
|
||
)}
|
||
<p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
|
||
{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
|
||
<p className="mt-3 text-[12px] text-[#64748b]">
|
||
语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function confidenceLabel(value: InsightCardData['semanticConfidence']) {
|
||
return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
|
||
}
|
||
|
||
function dataLabel(value: InsightCardData['dataConfidence']) {
|
||
return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
|
||
}
|
||
```
|
||
|
||
Create `apps/web/components/analysis/EvidenceList.tsx`:
|
||
|
||
```tsx
|
||
import type { EvidenceItem } from '@ftb/shared';
|
||
|
||
export function EvidenceList({ items }: { items: EvidenceItem[] }) {
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
{items.map((item, index) => (
|
||
<button
|
||
key={`${item.label}-${index}`}
|
||
type="button"
|
||
className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
|
||
disabled={!item.drilldown}
|
||
title={item.sourceLabel ?? item.sourceDomain}
|
||
>
|
||
<span className="text-[#64748b]">{item.label}</span>
|
||
<span className="ml-1 font-semibold text-[#0f172a]">{item.value}{item.unit ?? ''}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `apps/web/components/analysis/AnalysisReport.tsx`:
|
||
|
||
```tsx
|
||
import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
|
||
import { EvidenceList } from './EvidenceList';
|
||
|
||
export function AnalysisReport({ report }: { report: AnalysisReportData }) {
|
||
return (
|
||
<section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||
<h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
|
||
<p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
|
||
<ReportSection title="关键发现" items={report.keyFindings} />
|
||
<div className="mt-4">
|
||
<p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
|
||
<EvidenceList items={report.evidence} />
|
||
</div>
|
||
<ReportSection title="建议动作" items={report.suggestions} />
|
||
<div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
|
||
<p>{report.dataScope.timeDescription}</p>
|
||
<p>{report.dataScope.permissionDescription}</p>
|
||
<p>{report.dataScope.metricFormulaDescription}</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ReportSection({ title, items }: { title: string; items: string[] }) {
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div className="mt-4">
|
||
<p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
|
||
<ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
|
||
{items.map((item) => <li key={item}>• {item}</li>)}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `apps/web/components/analysis/FollowUpActions.tsx`:
|
||
|
||
```tsx
|
||
import type { FollowUp } from '@ftb/shared';
|
||
|
||
export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
|
||
if (followUps.length === 0) return null;
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
{followUps.map((item) => (
|
||
<button
|
||
key={`${item.type}-${item.label}`}
|
||
type="button"
|
||
onClick={() => item.type === 'question' ? onAsk(item.prompt) : undefined}
|
||
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm hover:bg-white"
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `apps/web/components/analysis/AnalysisResultBlock.tsx`:
|
||
|
||
```tsx
|
||
import type { AnalysisResponse } from '@ftb/shared';
|
||
import { AnalysisChart } from './AnalysisChart';
|
||
import { AnalysisReport } from './AnalysisReport';
|
||
import { FollowUpActions } from './FollowUpActions';
|
||
import { InsightCard } from './InsightCard';
|
||
|
||
export function AnalysisResultBlock({ response, onAsk }: { response: AnalysisResponse; onAsk: (prompt: string) => void }) {
|
||
if (!response.ok) {
|
||
return (
|
||
<div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm">
|
||
<p className="font-medium text-[#0f172a]">{response.message}</p>
|
||
{response.clarificationOptions && (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{response.clarificationOptions.map((option) => (
|
||
<button key={option.prompt} type="button" onClick={() => onAsk(option.prompt)} className="rounded-full border px-3 py-2 text-[13px] hover:bg-[#f8fafc]">
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<InsightCard insight={response.insight} />
|
||
<AnalysisChart spec={response.chart} />
|
||
<AnalysisReport report={response.report} />
|
||
<FollowUpActions followUps={response.followUps} onAsk={onAsk} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Modify AI Assistant page message model**
|
||
|
||
In `apps/web/app/wenfan-xiaobao/page.tsx`, add imports:
|
||
|
||
```ts
|
||
import type { AnalysisResponse } from '@ftb/shared';
|
||
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
|
||
import { requestAnalysis } from '@/lib/analysis-api';
|
||
import { useAuthStore } from '@/stores/useAuthStore';
|
||
import { useMemberStore } from '@/stores/useMemberStore';
|
||
```
|
||
|
||
Add current role permissions inside `WenfanXiaobaoPage`:
|
||
|
||
```ts
|
||
const user = useAuthStore((state) => state.user);
|
||
const roles = useMemberStore((state) => state.roles);
|
||
const currentPermissions = useMemo(
|
||
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||
[roles, user?.roleId],
|
||
);
|
||
```
|
||
|
||
Extend `ChatMessage` union:
|
||
|
||
```ts
|
||
| {
|
||
id: string;
|
||
role: 'assistant';
|
||
type: 'analysis';
|
||
response: AnalysisResponse;
|
||
}
|
||
```
|
||
|
||
Update `INITIAL_MESSAGES[0].content`:
|
||
|
||
```ts
|
||
content:
|
||
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
|
||
```
|
||
|
||
Change `askQuestion` to async and call analysis first:
|
||
|
||
```ts
|
||
async function askQuestion(rawQuestion: string) {
|
||
const question = rawQuestion.trim();
|
||
if (!question) return;
|
||
|
||
const userMessage: ChatMessage = {
|
||
id: `user-${Date.now()}`,
|
||
role: 'user',
|
||
type: 'text',
|
||
content: question,
|
||
};
|
||
const optimisticMessages = [...messages, userMessage];
|
||
setMessages(optimisticMessages);
|
||
setInput('');
|
||
|
||
try {
|
||
const analysis = await requestAnalysis(
|
||
{ question, context: { surface: 'ai_assistant' } },
|
||
currentPermissions,
|
||
);
|
||
const assistantMessage: ChatMessage = {
|
||
id: `analysis-${Date.now()}`,
|
||
role: 'assistant',
|
||
type: 'analysis',
|
||
response: analysis,
|
||
};
|
||
const nextMessages = [...optimisticMessages, assistantMessage];
|
||
setMessages(nextMessages);
|
||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||
return;
|
||
} catch {
|
||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||
const nextUserQuestionCount = userQuestionCount + 1;
|
||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||
const assistantMessage: ChatMessage =
|
||
results.length > 0
|
||
? { id: `article-${Date.now()}`, role: 'assistant', type: 'article', result: results[0] }
|
||
: {
|
||
id: `fallback-${Date.now()}`,
|
||
role: 'assistant',
|
||
type: 'fallback',
|
||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||
};
|
||
const nextMessages = [...optimisticMessages, assistantMessage];
|
||
setMessages(nextMessages);
|
||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||
}
|
||
}
|
||
```
|
||
|
||
Update form handlers to call async function without awaiting:
|
||
|
||
```ts
|
||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
void askQuestion(input);
|
||
}
|
||
```
|
||
|
||
In `MessageRow`, render analysis:
|
||
|
||
```tsx
|
||
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
|
||
```
|
||
|
||
- [ ] **Step 3: Run frontend type-check**
|
||
|
||
Run: `pnpm --filter web type-check`
|
||
|
||
Expected: command exits `0`.
|
||
|
||
- [ ] **Step 4: Run focused frontend tests**
|
||
|
||
Run: `pnpm --filter web test -- analysis-api analysis-chart-renderer wenfan-help-search`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/components/analysis apps/web/app/wenfan-xiaobao/page.tsx
|
||
git commit -m "feat(ai-analysis): 在AI助手展示业务分析结果"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Product, Project, and Version Context Entrypoints
|
||
|
||
**Files:**
|
||
- Create: `apps/web/components/analysis/AnalysisEntryButton.tsx`
|
||
- Create: `apps/web/components/analysis/AnalysisContextDrawer.tsx`
|
||
- Modify: `apps/web/app/products/[id]/page.tsx`
|
||
- Modify: `apps/web/app/projects/[id]/page.tsx`
|
||
- Modify: `apps/web/app/versions/[id]/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `requestAnalysis`, `AnalysisResultBlock`.
|
||
- Produces: context-aware analysis drawer on product, project, and version detail pages.
|
||
|
||
- [ ] **Step 1: Create context drawer components**
|
||
|
||
Create `apps/web/components/analysis/AnalysisEntryButton.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { BarChart3, Sparkles } from 'lucide-react';
|
||
|
||
export function AnalysisEntryButton({ onClick, compact = false }: { onClick: () => void; compact?: boolean }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 py-1.5 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur hover:bg-white"
|
||
title="智能分析"
|
||
>
|
||
{compact ? <BarChart3 className="h-3.5 w-3.5" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||
智能分析
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `apps/web/components/analysis/AnalysisContextDrawer.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { FormEvent, useState } from 'react';
|
||
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||
import { X } from 'lucide-react';
|
||
import { requestAnalysis } from '@/lib/analysis-api';
|
||
import { AnalysisResultBlock } from './AnalysisResultBlock';
|
||
|
||
export function AnalysisContextDrawer({
|
||
open,
|
||
title,
|
||
context,
|
||
permissions,
|
||
onClose,
|
||
}: {
|
||
open: boolean;
|
||
title: string;
|
||
context: NonNullable<AnalysisRequest['context']>;
|
||
permissions: string[];
|
||
onClose: () => void;
|
||
}) {
|
||
const [question, setQuestion] = useState('');
|
||
const [response, setResponse] = useState<AnalysisResponse | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
if (!open) return null;
|
||
|
||
async function ask(prompt: string) {
|
||
const value = prompt.trim();
|
||
if (!value) return;
|
||
setLoading(true);
|
||
try {
|
||
setResponse(await requestAnalysis({ question: value, context }, permissions));
|
||
setQuestion('');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function submit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
void ask(question);
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||
<aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
|
||
<header className="flex h-14 items-center justify-between border-b border-white/70 px-5">
|
||
<div>
|
||
<p className="text-[13px] font-semibold text-[#0f172a]">{title}</p>
|
||
<p className="text-[11px] text-[#64748b]">只读取当前上下文和你已有权限的数据</p>
|
||
</div>
|
||
<button type="button" onClick={onClose} className="rounded-full p-2 text-[#64748b] hover:bg-white">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</header>
|
||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||
<form onSubmit={submit} className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-sm">
|
||
<textarea
|
||
value={question}
|
||
onChange={(event) => setQuestion(event.target.value)}
|
||
rows={3}
|
||
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] outline-none placeholder:text-[#94a3b8]"
|
||
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
|
||
/>
|
||
<div className="flex justify-end">
|
||
<button disabled={loading || !question.trim()} className="rounded-full bg-[#0f172a] px-4 py-2 text-[13px] font-medium text-white disabled:bg-[#cbd5e1]">
|
||
{loading ? '分析中' : '开始分析'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
{response && <AnalysisResultBlock response={response} onAsk={ask} />}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add version detail entry**
|
||
|
||
In `apps/web/app/versions/[id]/page.tsx`, import:
|
||
|
||
```ts
|
||
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
|
||
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
|
||
```
|
||
|
||
Add state near other modal states:
|
||
|
||
```ts
|
||
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
|
||
const currentPermissions = currentRole?.permissions ?? [];
|
||
```
|
||
|
||
Place `<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />` in the version header action area.
|
||
|
||
Render drawer near existing modals:
|
||
|
||
```tsx
|
||
<AnalysisContextDrawer
|
||
open={showAnalysisDrawer}
|
||
title={`版本智能分析 · ${version.name}`}
|
||
context={{ surface: 'version_detail', versionId: version.id }}
|
||
permissions={currentPermissions}
|
||
onClose={() => setShowAnalysisDrawer(false)}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Add product and project entries**
|
||
|
||
In `apps/web/app/products/[id]/page.tsx`, import `AnalysisEntryButton`, `AnalysisContextDrawer`, `useAuthStore`, and `useMemberStore`. Add `showAnalysisDrawer` state plus:
|
||
|
||
```ts
|
||
const user = useAuthStore((state) => state.user);
|
||
const roles = useMemberStore((state) => state.roles);
|
||
const currentPermissions = useMemo(
|
||
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||
[roles, user?.roleId],
|
||
);
|
||
```
|
||
|
||
Place the button in the product page header and render:
|
||
|
||
```tsx
|
||
<AnalysisContextDrawer
|
||
open={showAnalysisDrawer}
|
||
title={`智能分析 · ${currentProduct.name}`}
|
||
context={{ surface: 'product_detail', productId: currentProduct.id }}
|
||
permissions={currentPermissions}
|
||
onClose={() => setShowAnalysisDrawer(false)}
|
||
/>
|
||
```
|
||
|
||
In `apps/web/app/projects/[id]/page.tsx`, reuse the existing `user` and `roles` variables, add:
|
||
|
||
```ts
|
||
const currentPermissions = useMemo(
|
||
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||
[roles, user?.roleId],
|
||
);
|
||
```
|
||
|
||
Place the button in the project page header and render:
|
||
|
||
```tsx
|
||
<AnalysisContextDrawer
|
||
open={showAnalysisDrawer}
|
||
title={`项目智能分析 · ${project.name}`}
|
||
context={{ surface: 'project_detail', projectId: project.id }}
|
||
permissions={currentPermissions}
|
||
onClose={() => setShowAnalysisDrawer(false)}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 4: Run frontend type-check**
|
||
|
||
Run: `pnpm --filter web type-check`
|
||
|
||
Expected: command exits `0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/components/analysis/AnalysisEntryButton.tsx \
|
||
apps/web/components/analysis/AnalysisContextDrawer.tsx \
|
||
'apps/web/app/products/[id]/page.tsx' \
|
||
'apps/web/app/projects/[id]/page.tsx' \
|
||
'apps/web/app/versions/[id]/page.tsx'
|
||
git commit -m "feat(ai-analysis): 添加详情页智能分析入口"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Documentation, Verification, and UI QA
|
||
|
||
**Files:**
|
||
- Modify: `docs/agent-spec.md`
|
||
- Modify: `docs/roadmap.md`
|
||
- Modify: `docs/workflow.md`
|
||
- Optional screenshots saved outside git unless the user asks to keep them.
|
||
|
||
**Interfaces:**
|
||
- Consumes: all previous tasks.
|
||
- Produces: final documented implementation boundary and verification evidence.
|
||
|
||
- [ ] **Step 1: Update docs after implementation**
|
||
|
||
Update `docs/agent-spec.md` Business Analysis Agent section:
|
||
|
||
```md
|
||
Business Analysis Agent 第一版已接入 `/api/v1/ai/analysis`、`/wenfan-xiaobao` 和产品/项目/版本详情页上下文入口。第一版支持 Template Strategy 和 Deterministic Rule Composition,AI Planning 只生成受控计划建议并经过 Analysis Plan Processor 校验。图表契约为 Unified ChartSpec,前端通过 ECharts Renderer 渲染。
|
||
```
|
||
|
||
Update `docs/roadmap.md` V3.4:
|
||
|
||
```md
|
||
### V3.4 — Business Analysis Agent(已完成第一版)
|
||
```
|
||
|
||
Add completion bullets for:
|
||
|
||
- Shared analysis contract.
|
||
- Backend analysis endpoint and Metric Engine.
|
||
- ECharts Renderer.
|
||
- AI Assistant analysis conversation.
|
||
- Product/project/version context entrypoints.
|
||
|
||
Update `docs/workflow.md` with the implemented endpoint:
|
||
|
||
```md
|
||
业务分析请求走 `POST /api/v1/ai/analysis`。前端必须传入 `surface` 和可用上下文 ID;后端基于当前用户和上下文再次收窄范围,不能只信任前端。
|
||
```
|
||
|
||
- [ ] **Step 2: Run full verification commands**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
pnpm --filter @ftb/shared type-check
|
||
pnpm --filter server test -- analysis
|
||
pnpm --filter server type-check
|
||
pnpm --filter web test -- analysis
|
||
pnpm --filter web type-check
|
||
git diff --check
|
||
```
|
||
|
||
Expected:
|
||
|
||
- Shared type-check exits `0`.
|
||
- Server focused tests pass.
|
||
- Server type-check exits `0`.
|
||
- Web focused tests pass.
|
||
- Web type-check exits `0`.
|
||
- `git diff --check` prints no whitespace errors.
|
||
|
||
- [ ] **Step 3: Run local UI smoke if dev server is available**
|
||
|
||
If a Next.js dev server is already running, run:
|
||
|
||
```bash
|
||
curl -I http://localhost:3000/wenfan-xiaobao
|
||
curl -I http://localhost:3000/products
|
||
curl -I http://localhost:3000/projects
|
||
curl -I http://localhost:3000/versions
|
||
```
|
||
|
||
Expected: each returns `HTTP/1.1 200` or `HTTP/1.1 307` for auth redirect depending on current local auth state.
|
||
|
||
If no dev server is running, start it:
|
||
|
||
```bash
|
||
pnpm dev --filter=web
|
||
```
|
||
|
||
Keep the session running only long enough to verify pages and screenshots, then stop it before final response.
|
||
|
||
- [ ] **Step 4: Visual QA with browser testing**
|
||
|
||
Use the browser testing tool or Playwright to inspect:
|
||
|
||
- `/wenfan-xiaobao`
|
||
- One product detail page
|
||
- One project detail page
|
||
- One version detail page
|
||
|
||
Checks:
|
||
|
||
- Insight Card appears before chart and report.
|
||
- Large number is the primary visual in numeric insight.
|
||
- Horizontal bars are single-color and rounded.
|
||
- Line chart has smooth line, subtle area fill, and minimal axes.
|
||
- Long labels do not overflow on desktop and mobile widths.
|
||
- No-data and no-permission responses do not show empty chart frames.
|
||
|
||
- [ ] **Step 5: Commit final docs and polish**
|
||
|
||
```bash
|
||
git add docs/agent-spec.md docs/roadmap.md docs/workflow.md
|
||
git commit -m "docs(ai-analysis): 更新业务分析Agent落地状态"
|
||
```
|
||
|
||
- [ ] **Step 6: Final branch status**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git status --short
|
||
git log --oneline -8
|
||
```
|
||
|
||
Expected: working tree is clean except for intentionally uncommitted local files, and recent commits show the task commits in order.
|