diff --git a/crates/server/src/system_qa/activity.rs b/crates/server/src/system_qa/activity.rs index a7273f7..88a4166 100644 --- a/crates/server/src/system_qa/activity.rs +++ b/crates/server/src/system_qa/activity.rs @@ -734,15 +734,16 @@ pub(super) fn answer( ) }) .collect(); - let chart_rows: Vec<_> = rows + let mut chart_rows: Vec<_> = rows .into_iter() .map(|((label, group), values)| json!({"label":label,"group":group,"values":values})) .collect(); + super::heatmap::expand(&mut chart_rows, &dates, &events, data, intent.role, intent.groups); let charts = if intent.chart { let title = if intent.groups { "各组活动统计" } else { "成员活动统计" }; if intent.chart_kind == "activity_heatmap" { vec![ - json!({"kind":"activity_heatmap","title":title,"metric":metric,"dates":dates,"rows":chart_rows,"from":from,"through":through,"observedAt":data["observedAt"],"coverageComplete":complete}), + json!({"kind":"activity_heatmap","detailMode":intent.role,"expanded":chart_rows.iter().any(|row|row["cells"].is_array()),"detailNote":if chart_rows.len()*dates.len()>6000 {"范围较大,仅显示活动次数;缩小时间或人员范围可展开每日指标。"}else{""},"title":title,"metric":metric,"dates":dates,"rows":chart_rows,"from":from,"through":through,"observedAt":data["observedAt"],"coverageComplete":complete}), ] } else { let mut ranked = bar_values; diff --git a/crates/server/src/system_qa/heatmap.rs b/crates/server/src/system_qa/heatmap.rs new file mode 100644 index 0000000..d24d5f0 --- /dev/null +++ b/crates/server/src/system_qa/heatmap.rs @@ -0,0 +1,165 @@ +use chrono::{DateTime, FixedOffset}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Default)] +struct Cell { + count: u64, + latest: Option>, + additions: Option, + deletions: Option, + incomplete: bool, + requirements: BTreeSet, + bugs: BTreeSet, + created: u64, + published: u64, + cases: u64, +} +impl Cell { + fn event(&mut self, event: &Value) { + self.count += 1; + if let Some(at) = event["at"].as_str().and_then(|at| DateTime::parse_from_rfc3339(at).ok()) + { + if self.latest.is_none_or(|latest| at > latest) { + self.latest = Some(at); + } + } + let kind = text(event, "kind"); + if kind == "commit" { + for (total, value) in [ + (&mut self.additions, &event["additions"]), + (&mut self.deletions, &event["deletions"]), + ] { + if let Some(value) = value.as_u64() { + *total = Some(total.unwrap_or_default() + value); + } else { + self.incomplete = true; + } + } + } + for requirement in event["requirements"].as_array().into_iter().flatten() { + let id = text(requirement, "externalId"); + if !id.is_empty() { + self.requirements.insert(id.into()); + } + } + if kind.starts_with("requirement") { + let id = text(event, "externalId"); + if !id.is_empty() { + self.requirements.insert(id.into()); + } + } + match kind { + "requirementCreated" => self.created += 1, + "requirementDelivered" => self.published += 1, + "testcaseCreated" => self.cases += 1, + "bugReported" => { + let id = text(event, "externalId"); + if !id.is_empty() { + self.bugs.insert(id.into()); + } + } + _ => {} + } + } + fn value(&self) -> Value { + json!({"count":self.count,"lastTime":self.latest.map(|at|at.with_timezone(&FixedOffset::east_opt(28800).expect("Shanghai")).format("%H:%M").to_string()),"additions":self.additions,"deletions":self.deletions,"lineStatsIncomplete":self.incomplete,"requirements":self.requirements.len(),"bugs":self.bugs.len(),"created":self.created,"published":self.published,"testcases":self.cases}) + } +} +fn text<'a>(value: &'a Value, key: &str) -> &'a str { + value[key].as_str().unwrap_or_default() +} + +pub fn expand( + rows: &mut [Value], + dates: &[String], + events: &[&Value], + data: &Value, + role: &str, + grouped: bool, +) { + if rows.len().saturating_mul(dates.len()) > 6000 { + return; + } + let mut index: BTreeMap> = BTreeMap::new(); + for (i, row) in rows.iter().enumerate() { + index.entry(text(row, "label").into()).or_default().push(i); + } + let date_index: BTreeMap<_, _> = + dates.iter().enumerate().map(|(i, date)| (date.as_str(), i)).collect(); + let mut cells: Vec> = + (0..rows.len()).map(|_| (0..dates.len()).map(|_| Cell::default()).collect()).collect(); + let mut totals: Vec = (0..rows.len()).map(|_| Cell::default()).collect(); + for event in events { + let Some(&day) = date_index.get(text(event, "date")) else { continue }; + let actor = + if text(event, "actor").is_empty() { "未关联账号" } else { text(event, "actor") }; + let group = + if text(event, "group").is_empty() { "待归属" } else { text(event, "group") }; + if let Some(indices) = index.get(if grouped { group } else { actor }) { + for &i in indices { + if !grouped && text(&rows[i], "group") != group { + continue; + } + cells[i][day].event(event); + totals[i].event(event); + } + } + } + if role == "code" { + for bug in data["bugs"].as_array().into_iter().flatten() { + let Some(&day) = date_index.get(text(bug, "date")) else { continue }; + let id = text(bug, "id"); + if id.is_empty() { + continue; + } + for actor in + bug["responsibles"].as_array().into_iter().flatten().filter_map(Value::as_str) + { + let group = data["members"] + .as_array() + .into_iter() + .flatten() + .find(|member| text(member, "name") == actor) + .map(|member| text(member, "group")) + .filter(|group| !group.is_empty()) + .unwrap_or("待归属"); + if let Some(indices) = index.get(if grouped { group } else { actor }) { + for &i in indices { + cells[i][day].bugs.insert(id.into()); + totals[i].bugs.insert(id.into()); + } + } + } + } + } + for (i, row) in rows.iter_mut().enumerate() { + row["cells"] = json!(cells[i].iter().map(Cell::value).collect::>()); + row["totals"] = totals[i].value(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cells_keep_last_time_known_lines_and_distinct_requirements_and_bugs() { + let dates = vec!["2026-11-07".into(), "2026-11-08".into()]; + let mut rows = vec![json!({"label":"Member A","group":"Team","values":[2,1]})]; + let events = [ + json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-07","at":"2026-11-07T09:00:00Z","additions":9,"deletions":6,"requirements":[{"externalId":"r1"}]}), + json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-07","at":"2026-11-07T18:35:00+08:00","additions":null,"deletions":1,"requirements":[{"externalId":"r1"}]}), + json!({"kind":"commit","actor":"Member A","group":"Team","date":"2026-11-08","requirements":[{"externalId":"r1"}]}), + ]; + let data = json!({"bugs":[{"id":"b1","date":"2026-11-07","responsibles":["Member A"]},{"id":"b1","date":"2026-11-07","responsibles":["Member A"]}]}); + expand(&mut rows, &dates, &events.iter().collect::>(), &data, "code", false); + assert_eq!(rows[0]["cells"][0]["lastTime"], "18:35"); + assert_eq!(rows[0]["cells"][0]["additions"], 9); + assert_eq!(rows[0]["cells"][0]["deletions"], 7); + assert_eq!(rows[0]["cells"][0]["requirements"], 1); + assert_eq!(rows[0]["totals"]["requirements"], 1); + assert_eq!(rows[0]["totals"]["bugs"], 1); + assert_eq!(rows[0]["totals"]["count"], 3); + assert!(rows[0]["cells"][1]["additions"].is_null()); + } +} diff --git a/crates/server/src/system_qa/mod.rs b/crates/server/src/system_qa/mod.rs index 8bf3e51..e71c2f9 100644 --- a/crates/server/src/system_qa/mod.rs +++ b/crates/server/src/system_qa/mod.rs @@ -3,6 +3,7 @@ mod activity; mod audit; mod business; mod devops; +mod heatmap; mod team; use async_trait::async_trait; use axum::{body::Body, http::Request, Router}; diff --git a/crates/server/src/system_qa/team.rs b/crates/server/src/system_qa/team.rs index cd3f9f2..179b8ac 100644 --- a/crates/server/src/system_qa/team.rs +++ b/crates/server/src/system_qa/team.rs @@ -148,11 +148,16 @@ fn role_summary( data["observedAt"].as_str().unwrap_or("未知"), if complete { "" } else { "采集覆盖待核验,不能据此判断零产出。" } ); - let rows: Vec<_> = rows + let mut rows: Vec<_> = rows .into_iter() .map(|((label, group), values)| json!({"label":label,"group":group,"values":values})) .collect(); - let chart = json!({"kind":"activity_heatmap","title":format!("{label} · 团队产出"),"metric":"已观测活动次数","dates":dates,"rows":rows,"from":from,"through":through,"coverageComplete":complete,"observedAt":data["observedAt"]}); + let scoped: Vec<_> = raw + .iter() + .filter(|event| kinds.contains(&event["kind"].as_str().unwrap_or_default())) + .collect(); + super::heatmap::expand(&mut rows, &dates, &scoped, data, role, false); + let chart = json!({"kind":"activity_heatmap","detailMode":role,"expanded":rows.iter().any(|row|row["cells"].is_array()),"detailNote":if rows.len()*dates.len()>6000 {"范围较大,仅显示活动次数;缩小时间或人员范围可展开每日指标。"}else{""},"title":format!("{label} · 团队产出"),"metric":"已观测活动次数","dates":dates,"rows":rows,"from":from,"through":through,"coverageComplete":complete,"observedAt":data["observedAt"]}); Ok(( content, chart, diff --git a/plugins/wecom-bot/scripts/channel.py b/plugins/wecom-bot/scripts/channel.py index 6d9cfc5..2e0a045 100644 --- a/plugins/wecom-bot/scripts/channel.py +++ b/plugins/wecom-bot/scripts/channel.py @@ -174,6 +174,8 @@ def render_grounded_chart(spec, font_path): return render_grounded_bars(spec, font_path) if spec.get("kind") != "activity_heatmap": raise ValueError("unsupported_chart") + if spec.get("expanded") and any(row.get("cells") for row in spec.get("rows", [])): + return render_expanded_heatmap(spec, font_path) dates = spec.get("dates") or [] rows = spec.get("rows") or [] if not 1 <= len(dates) <= 366 or len(rows) > 500: @@ -218,6 +220,89 @@ def render_grounded_chart(spec, font_path): return {"msgtype": "image", "image": {"base64": base64.b64encode(raw).decode(), "md5": hashlib.md5(raw, usedforsecurity=False).hexdigest()}} +def render_expanded_heatmap(spec, font_path): + from PIL import Image, ImageDraw, ImageFont + from datetime import date + import math + dates, rows = spec.get("dates") or [], spec.get("rows") or [] + if not 1 <= len(dates) <= 366 or len(rows) > 500: + raise ValueError("invalid_chart_size") + shown = rows[:60] + width = min(2800, max(1500, 380+len(dates)*220)) + row_height = 78 + height = max(350, 250+len(shown)*row_height) + picture = Image.new("RGB", (width, height), "#F4F7F6") + draw = ImageDraw.Draw(picture) + font = lambda size: ImageFont.truetype(font_path, size) + draw.rounded_rectangle((20, 20, width-20, height-20), radius=20, fill="white") + draw.text((45, 38), str(spec.get("title") or "成员产出")[:55], font=font(29), fill="#274B44") + draw.text((45, 84), f"{spec.get('from', '')} — {spec.get('through', '')} · 上海时间 · 展开指标", font=font(18), fill="#64746F") + left_edge, right_edge = 220, width-195 + cell_width = (right_edge-left_edge)/len(dates) + mode = spec.get("detailMode", "code") + headers = ["提交", "需求", "Bug"] if mode == "code" else ["活动", "新建", "发布"] if mode == "product" else ["活动", "用例", "Bug"] + for i, label in enumerate(headers): + draw.text((right_edge+12+i*51, 126), label, font=font(16), fill="#73807C") + for column, day in enumerate(dates): + left = left_edge+column*cell_width + weekday = date.fromisoformat(day).weekday() + weekend = weekday >= 5 + if weekend: draw.rounded_rectangle((left, 114, left+cell_width-4, 163), radius=4, fill="#F4EEE7") + if cell_width >= 40: + draw.text((left+8, 116), day[5:], font=font(17), fill="#A46B36" if weekend else "#64746F") + draw.text((left+8, 140), "周"+"一二三四五六日"[weekday], font=font(15), fill="#A46B36" if weekend else "#73807C") + maximum = max((v for row in shown for v in row.get("values", []) if isinstance(v, (int, float)) and math.isfinite(v) and v > 0), default=1) + def centered(text, left, top, size, color="#263F39"): + face=font(size);box=draw.textbbox((0,0),text,font=face) + draw.text((left+(cell_width-(box[2]-box[0]))/2,top),text,font=face,fill=color) + def number(value): return "—" if value is None else f"{int(value):,}" + for index, row in enumerate(shown): + top=174+index*row_height + draw.text((45,top+20),str(row.get("label") or "未关联账号")[:12],font=font(21),fill="#263F39") + for column, day in enumerate(dates): + left=left_edge+column*cell_width + value=(row.get("values") or [])[column] if column0 else 0 + factor=.12+.55*math.sqrt(value/maximum) if value else 0 + shade=tuple(round(245*(1-factor)+green*factor) for green in (72,137,119)) + draw.rounded_rectangle((left,top,left+max(1,cell_width-4),top+row_height-5),radius=4,fill=shade) + cell=(row.get("cells") or [])[column] if column2_000_000: raise ValueError("chart_exceeds_image_limit") + return {"msgtype":"image","image":{"base64":base64.b64encode(raw).decode(),"md5":hashlib.md5(raw,usedforsecurity=False).hexdigest()}} + + def render_grounded_bars(spec, font_path): from PIL import Image, ImageDraw, ImageFont import math diff --git a/plugins/wecom-bot/scripts/gateway.py b/plugins/wecom-bot/scripts/gateway.py index 286db5d..6df843e 100644 --- a/plugins/wecom-bot/scripts/gateway.py +++ b/plugins/wecom-bot/scripts/gateway.py @@ -89,7 +89,7 @@ async def ask(self, project, question, history, frame, stream): answer, citations, event, last_update = "", [], "", 0.0 completed = False charts = [] - async with self.http.post(os.environ["SHEPHERD_URL"].rstrip("/")+"/rag/ask/stream", json=body, headers=headers) as response: + async with self.http.post(os.environ["SHEPHERD_URL"].rstrip("/")+"/rag/ask/stream", json=body, headers=headers, read_bufsize=4*1024*1024) as response: if response.status != 200: raise RuntimeError("backend_unavailable") async for raw in response.content: diff --git a/web/e2e/grounded-qa.spec.ts b/web/e2e/grounded-qa.spec.ts index a04ed19..9ace268 100644 --- a/web/e2e/grounded-qa.spec.ts +++ b/web/e2e/grounded-qa.spec.ts @@ -125,3 +125,27 @@ test('pending direct authorization reuses the channel account and preserves grou expect(saved?.allowBinding).toBe(false) expect(saved?.conversations).toEqual([group, { kind: 'direct', externalId: 'user-a', projectId: 'project', enabled: true }]) }) + + +test('answer heatmaps expand daily code indicators and hide zero bugs by default', async ({ page }, testInfo) => { + await setup(page) + await page.route('**/rag/ask/stream', route => { + const empty = { count: 0, lastTime: null, additions: null, deletions: null, requirements: 0, bugs: 0, created: 0, published: 0, testcases: 0 } + const chart = { kind: 'activity_heatmap', title: '成员展开热力图', metric: '已采集代码提交', detailMode: 'code', expanded: true, from: '2026-11-07', through: '2026-11-08', dates: ['2026-11-07', '2026-11-08'], rows: [{ label: '成员甲', group: '研发组', values: [2, 0], cells: [{ ...empty, count: 2, lastTime: '20:35', additions: 6191, deletions: 2150, requirements: 3 }, { ...empty, bugs: 2 }], totals: { ...empty, count: 2, requirements: 3, bugs: 2 } }] } + return route.fulfill({ contentType: 'text/event-stream', body: [['chart', chart], ['chunk', { delta: '已核对提交记录。' }], ['done', { grounded: true }]].map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join('') }) + }) + await page.goto('/p/project/chat') + await page.locator('textarea:not([aria-hidden="true"])').fill('团队代码热力图') + await page.locator('textarea:not([aria-hidden="true"])').press('Enter') + const chart = page.getByRole('region', { name: '成员展开热力图' }) + await expect(chart.getByText('20:35', { exact: true })).toBeVisible() + await expect(chart.getByText('+6,191 / −2,150')).toBeVisible() + await expect(chart.getByText('关联需求 3')).toBeVisible() + await expect(chart.getByText('Bug 2', { exact: true })).toBeVisible() + await expect(chart.getByText('Bug 0', { exact: true })).toHaveCount(0) + await expect(chart.getByText('周六', { exact: true })).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('rag-expanded-heatmap.png') }) + await chart.getByRole('button', { name: '收起格子指标' }).click() + await expect(chart.getByText('20:35', { exact: true })).toHaveCount(0) + await expect(chart.getByRole('button', { name: '展开格子指标' })).toBeVisible() +}) diff --git a/web/src/components/GroundedChart.tsx b/web/src/components/GroundedChart.tsx index ead4713..777adc4 100644 --- a/web/src/components/GroundedChart.tsx +++ b/web/src/components/GroundedChart.tsx @@ -1,11 +1,17 @@ -import { Tooltip } from 'antd' +import { Button, Tooltip } from 'antd' +import { useState } from 'react' +type DailyCell = { count: number; lastTime?: string | null; additions?: number | null; deletions?: number | null; lineStatsIncomplete?: boolean; requirements: number; bugs: number; created: number; published: number; testcases: number } +type HeatmapRowSpec = { label: string; group: string; values: number[]; cells?: DailyCell[]; totals?: DailyCell } type HeatmapSpec = { kind: 'activity_heatmap' title: string metric: string dates: string[] - rows: { label: string; group: string; values: number[] }[] + rows: HeatmapRowSpec[] + detailMode?: string + expanded?: boolean + detailNote?: string from: string through: string observedAt?: string @@ -17,14 +23,18 @@ type BarSpec = { kind: 'bar'; title: string; metric: string; labels: string[]; v export type GroundedChartSpec = HeatmapSpec | BarSpec export function GroundedChart({ chart }: { chart: GroundedChartSpec }) { + const [showDetails, setShowDetails] = useState(true) if (chart.kind === 'bar') return if (chart.kind !== 'activity_heatmap' || !Array.isArray(chart.rows) || !Array.isArray(chart.dates) || !chart.dates.length) return null const dates = chart.dates.slice(0, 366) const rows = chart.rows.slice(0, 500) const maximum = rows.reduce((maximum, row) => row.values.reduce((current, value) => Number.isFinite(value) && value >= 0 ? Math.max(current, value) : current, maximum), 1) - const columns = `minmax(92px, 140px) repeat(${dates.length}, minmax(27px, 1fr))` + const detailed = rows.some(row => row.cells?.length) + const expanded = detailed && showDetails + const mode = chart.detailMode || 'code' + const columns = `minmax(86px, 120px) repeat(${dates.length}, minmax(${expanded ? 76 : 27}px, 1fr))${detailed ? ' repeat(3, 32px)' : ''}` return
-
{chart.title}
+
{chart.title}{detailed && }
{chart.from && chart.through ? `${chart.from} ~ ${chart.through} · 上海时间` : '当前查询快照'}{chart.metric ? ` · ${chart.metric}` : ''}{chart.granularity === 'weekly' ? ' · 按周汇总' : ''}
@@ -32,26 +42,37 @@ export function GroundedChart({ chart }: { chart: GroundedChartSpec }) { {dates.map((date) => { const weekday = new Date(`${date}T00:00:00Z`).getUTCDay() const weekend = weekday === 0 || weekday === 6 - return {date.slice(5)} + return {date.slice(5)}周{'日一二三四五六'[weekday]} })} - {rows.map((row, index) => )} + {detailed && (mode === 'code' ? ['提交', '需求', 'Bug'] : mode === 'product' ? ['活动', '新建', '发布'] : ['活动', '用例', 'Bug']).map(label => {label})} + {rows.map((row, index) => )}
+ {chart.detailNote &&
{chart.detailNote}
} {!rows.length &&
所选范围暂未观测到活动记录。
} -
颜色越深,已采集活动越多;空白表示未观测到记录。{chart.coverageComplete === false && '采集覆盖尚不完整,不能据此判断没有工作。'}
+
颜色越深,已采集活动越多;空白表示未观测到记录。* 表示行数不全;期间需求与 Bug 合计分别去重。{chart.coverageComplete === false && '采集覆盖尚不完整,不能据此判断没有工作。'}
} -function HeatmapRow({ row, dates, maximum }: { row: HeatmapSpec['rows'][number]; dates: string[]; maximum: number }) { +const number = (value?: number | null) => value == null ? '—' : value.toLocaleString() +function HeatmapRow({ row, dates, maximum, expanded, detailed, mode }: { row: HeatmapRowSpec; dates: string[]; maximum: number; expanded: boolean; detailed: boolean; mode: string }) { + const totals = row.totals + const summary = mode === 'code' ? [totals?.count, totals?.requirements, totals?.bugs] : mode === 'product' ? [totals?.count, totals?.created, totals?.published] : [totals?.count, totals?.testcases, totals?.bugs] return <> {row.label} {dates.map((date, index) => { const value = Number.isFinite(row.values[index]) && row.values[index] > 0 ? row.values[index] : 0 + const cell = row.cells?.[index] const intensity = value ? 15 + Math.round(65 * Math.sqrt(value / maximum)) : 0 - return - {value || '·'} + const content = expanded && cell && (cell.count > 0 || cell.bugs > 0) ? <> + {cell.lastTime || '—'}{cell.bugs > 0 && Bug {cell.bugs}} + {mode === 'code' ? <>{cell.count > 0 && {cell.additions == null && cell.deletions == null ? '行数未知' : `+${number(cell.additions)} / −${number(cell.deletions)}`}{cell.lineStatsIncomplete ? '*' : ''}}{cell.requirements > 0 && 关联需求 {cell.requirements}} : <>{cell.count} 次活动{mode === 'product' ? `新建 ${cell.created} · 发布 ${cell.published}` : `用例 ${cell.testcases}`}} + : value || '·' + return {row.label} · {date}:{cell?.count ?? value} 条已采集活动{cell?.lineStatsIncomplete && <>
部分行数缺失,当前仅为已知下限}}> + {content}
})} + {detailed && summary.map((value, index) => {value || '—'})} }