072403123_软件工程实践第二次作业:观澜 Waves 与 AI 结对开发

Ying丶靓白 2026-09-24 22:53:05

观澜 · Waves:与 AI 结对,把顶会论文变成可以探索的研究方向

作业信息内容
这个作业属于哪个课程2601_FZU_SE 软件工程实践
这个作业要求在哪里软件工程实践第二次作业——与 AI 结对编程(顶会热词统计)
这个作业的目标与 AI 结对完成需求分析、Figma 原型、顶会论文获取与管理、热词统计及趋势动图,部署到公网,并记录人工审阅、返工和验证过程。
学号072403123
其他参考资料课程要求、《构建之法》相关章节及作者公开配套资料、CVF Open Access、ECVA、Google TypeScript Style Guide;文末列出链接。

目录

  • 观澜 · Waves:与 AI 结对,把顶会论文变成可以探索的研究方向
  • 一、Git 仓库、代码规范与 AI 工具说明
  • 二、PSP 表格与过程偏差
  • 三、NABCD 需求分析
  • N:需求——用户想知道方向,而不只是得到论文列表
  • A:方法——把统计、关系和论文阅读连接起来
  • B:收益——先让结果可追溯,再谈效率提升
  • C:竞争——聚焦三顶会的连贯探索流程
  • D:交付——公开演示与可构建源码
  • 四、原型设计与交互规则
  • 五、成品展示
  • 5.1 先找到方向,再读论文
  • 5.2 看关键词之间的联系
  • 5.3 看热度怎样逐年变化
  • 5.4 获取和管理自己的论文
  • 5.5 了解数据来自哪里
  • 六、人机结对讨论过程:三个真实案例
  • 案例 A:从顶部菜单改为可折叠侧栏
  • 案例 B:动态榜单与可探索图谱的功能返工
  • 案例 C:近三万篇论文后的加载性能问题
  • 贡献与可解释性声明
  • 七、设计实现过程与统计口径
  • 7.1 功能结构与技术架构
  • 7.2 数据获取、关键词与热度定义
  • 7.3 数据增长后为什么会慢
  • 八、约 300 行关键代码与解释
  • 8.1 关键词规范化与标题兜底
  • 8.2 聚合缓存、Top 10、共现与趋势
  • 8.3 SQLite 保存、去重与个人删除标记
  • 8.4 趋势播放的时间推进
  • 九、测试、Git 与部署
  • 十、心路历程与收获
  • 十一、对 AI 结对伙伴的评价
  • 参考资料

一、Git 仓库、代码规范与 AI 工具说明

观澜 · Waves 是一个围绕 CVPR、ICCV、ECCV 论文构建的研究方向分析平台。它的使用路径是:先看热门方向,再查看关键词之间的联系,最后回到具体论文和官方原文。已有阅读清单的用户也可以输入题目、批量导入并维护自己的论文库。

项目地址或说明
CodeArts 学号仓库072403123 项目源码
代码规范codestyle.md,采用 Google TypeScript Style Guide 的相关原则,配合 Prettier
AI 工具WorkBuddy、DeepSeek、DeepSeek Harness;正文统一称为 Agent
趋势动图直接查看 2021—2026 热度走势;正文 5.3 节也有嵌入播放
公网应用https://waves.cobaltmc.dev/
Figma 设计文件观澜 · Waves|纸墨交互原型
Figma 交互演示打开交互原型
分支与版本dev 开发,完成后合并至默认分支 main;1.0.0 源码归档可从仓库 Tags 页面下载

下文将参与协作的 AI 编程助手统称为 Agent。辅助工具包括 Product Design、Figma 和 Ego Lite:分别用于设计方案讨论、专用原型编辑及真实浏览器操作与验证。应用代码由 React / TypeScript / Fastify 独立实现,没有使用原型工具导出代码作为成品。

我的工作主要是提出需求、决定审美和页面分工、操作与审阅产品、指出问题、要求复查结果以及决定方案是否采纳。Agent 主要承担代码、测试、采集脚本和文档初稿的编写。对我来说,重要的不是让 AI 一次生成多少代码,而是能否根据实际使用中的问题继续改对。

二、PSP 表格与过程偏差

开发前,我对需求分析、原型、编码、测试和交付做了时间预估。实际开发时,AI 负责实现和执行测试,我同步审阅页面、检查结果并提出修改意见,因此这里按人机协作的任务时间统计。实际耗时根据工具记录和 Git 时间戳整理,包含任务中的审阅与等待;交叉进行的工作合并记录,避免重复计算。

截至 9 月 24 日 22:20,已记录的协作时间约为 630.9 分钟,也就是 10 小时 31 分钟。下表保留原始预估;更早未完整计时的需求讨论,以及此后的文章润色与上传未计入本次实际合计。

工作内容预估耗时(分钟)实际耗时(分钟)说明
需求分析、设计文档、规范与环境155计入相关阶段与原型设计、初版实现交叉进行
原型设计与评审19525.1初版 Figma 写入与检查
功能编码与返工390367.3初版实现、核心功能调整及六年数据扩充
代码复审与测试135计入相关阶段随功能实现和问题修复同步进行
性能优化未单独预估23.0数据扩容后增加的专项优化
公网部署与验证60计入相关阶段包含在初版实现和机器迁移过程中
提交整理、机器迁移与 CodeArts30101.8切换开发机器、配置仓库、整理分支和版本
原型同步、文档与复盘120113.7更新原型、整理协作证据和撰写文章初稿
合计1,085630.9交叉工作只计一次

回头看,最容易低估的是返工。第一版页面能运行后,我仍发现图谱不够直观、年度变化不明显、侧栏和点击反馈不自然。这些问题需要实际操作才能发现,也让我意识到,“功能写完”和“功能好用”之间还有一段距离。

数据扩充也带来了计划外的工作。从 240 篇增加到近三万篇后,除了采集和清洗,还要处理重复统计、接口响应过大和关键词列表加载慢的问题。相反,AI 在搭建页面、编写接口和整理材料上节省了不少重复劳动。

这次的预估按任务拆分,实际记录则包含了多个交叉环节,所以我没有直接用两者相除来计算效率提升。下次会在每轮任务开始和结束时记录时间,让功能返工、审阅和测试各自花了多久更清楚。

三、NABCD 需求分析

N:需求——用户想知道方向,而不只是得到论文列表

课程中的小刚对计算机视觉感兴趣,却缺乏检索关键词。面对三大顶会的论文目录,他很难靠逐篇阅读总结研究热点。对于这样的初学者,平台需要回答三个问题:哪些方向值得关注、关键词之间有什么联系、某个方向的热度如何随会议和年份变化。每个统计结果还应该能找到支撑它的论文。

本次分析主要来自课程场景和我自己的试用,后续还需要找同学验证这些判断。

A:方法——把统计、关系和论文阅读连接起来

用户任务实现方式验收重点
获取论文输入单篇题目或导入 TXT/CSV 标题清单,检索官方信息,核对后入库摘要、自动关键词、原文链接可查看,重复与失败项分别反馈
管理论文SQLite 持久化,提供新增、编辑、删除、分页和筛选刷新后仍保存;标题精确查询,编号、关键词等模糊查询
本地找不到区分真正未命中与被筛选隐藏,再提供联网获取结果不因年份筛选导致空列表就误判本地不存在
发现方向当前会议/年份范围的 Top 10 榜单和相关论文阅读区排名来自有效库数据,点击后有明确选中反馈与论文更新
探索联系D3 力导向图谱,节点表示关键词,边表示同篇论文共现筛选、拖动、缩放,点击节点显示相关论文
比较变化ECharts 轨迹图、年度对比条、2021—2026 时间轴播放、暂停、重播、速度选择,三会议颜色固定

设计上,我选择了纸墨配色、少量强调色和可折叠侧栏。热门方向负责“选一个方向开始读”,图谱负责“探索相关概念”,趋势负责“比较时间变化”。最初热门方向与图谱都有网络图,我认为信息重复,后来要求明确分工。保留筛选和当前关键词,可以减少跨页后重新选择的操作。

B:收益——先让结果可追溯,再谈效率提升

预期收益是减少手工整理论文、跨会议比较和反复查找关键词的步骤。点击热词就能查看论文,用户可以判断统计背后的材料,而不必盲信一个排名。本次未做计时对照实验,因此不声称提升了某个百分比的效率。后续可以让同学分别用会议目录和 Waves 完成相同任务,比较耗时、成功率与误解次数。

C:竞争——聚焦三顶会的连贯探索流程

替代方式已有价值Waves 的定位自身限制
会议官网、CVF、ECVA原始论文信息与官方原文入口把跨会议统计、关键词关系和论文阅读放在同一流程信息权威性仍以官方来源为准
DBLP 等书目检索查作者、标题、会议与出版信息对尚不知道具体论文题目的用户提供方向入口不追求同等书目覆盖范围
综合学术搜索平台围绕已有关键词查找材料聚焦 CVPR、ICCV、ECCV,统一年份和统计口径不能替代成熟搜索服务
表格与手工整理灵活、容易修改,适合小型清单自动去重、统计、动态对比与论文联动少量资料时手工处理也可能更合适

我没有打算做一个覆盖所有领域的学术搜索引擎,而是希望把三顶会的探索流程做顺:一个热词如何产生、使用多少篇样本、哪些论文支持这个结论,都能够解释清楚。

D:交付——公开演示与可构建源码

当前应用通过公网域名访问,源码托管在北京四 CodeArts 学号项目,原型通过 Figma 分享。班级后续通知允许使用其他云服务器,我沿用已有的 RainYun 服务器,通过 Docker 和 Caddy 提供公网访问。

四、原型设计与交互规则

原型工具为 Figma。先与 AI 讨论信息架构和视觉方案,再由我审阅,确认后写入专用原型文件;在产品返工和数据扩容后,又同步了原型状态。当前设计覆盖热门方向、关键词图谱、趋势对比、论文库、导入、数据说明和详情等页面,并补充六年播放状态和课程总览。

我采纳了纸墨配色和简洁的内容层次,但没有采用最初的顶部导航。我认为分析工具需要反复切换模块,也需要给图表留足空间,所以改为左侧可折叠导航。后来又要求将折叠开关移到上方、统一栏目对齐、减少冗余说明。

原型:Top 10 与图谱同屏的课程总览

网站默认进入总览首页,同屏展示 Top 10 热门方向与关键词图谱。点击榜单或图谱节点,下方会显示对应论文;会议和年份筛选同时作用于两个区域。热门方向和图谱仍保留独立页面,方便进一步阅读与探索。

原型:论文导入流程

导入原型将流程分成输入、核对和结果三个阶段,图中使用的是设计阶段的示例数据。

主要交互规则如下:点击侧栏切换模块;点击榜单方向更新阅读区;从阅读区进入图谱时携带方向和筛选;图谱点击节点更新对应论文;论文标题进入详情,原文链接跳转官方来源;新增与编辑进入表单,删除先确认;导入先核对候选,再确认写入;趋势播放推进年份,手动选择年份后暂停。加载、空数据、错误和重试也需要给出反馈。

Figma 已开启“任何人可查看”,设计文件和交互演示链接都放在文章开头。

五、成品展示

下面以桌面浏览器展示主要功能。最终数据范围为 2021—2026 年,共享基础库包含 29,048 篇论文;每位访客的新增、修改和删除会影响自己看到的数量。

默认首页:Top 10 与关键词图谱同屏

默认首页把 Top 10 和关键词图谱放在同一屏,点击榜单或节点即可查看相关论文。

5.1 先找到方向,再读论文

图 1:热门方向与论文阅读区

进入网站后,左侧是当前范围的 Top 10,右侧是选中方向对应的论文。榜单旁直接放阅读区,是为了让用户看到一个方向后就能继续读,而不是再跳回列表搜索。

图 2:切换到 Image Segmentation 后的论文联动

点击另一个方向,选中状态、相关篇数和论文列表一起更新。这也是我曾要求返工的地方:点击应当有明确反馈,而不是像重新刷新了一次页面。

图 3:收起侧栏后的工作区

侧栏开关放在上方,收起后保留图标,把更多空间留给内容。统一对齐和开合动画让模块切换更自然。

5.2 看关键词之间的联系

图 4:关键词共现网络

节点表示关键词,连线表示两个词在同篇论文中出现,粗细对应共现篇数。可以筛选会议、年份和最低共现数量,也可以拖动、缩放。默认显示部分高频节点,避免整页被线条淹没。

图 5:选择关键词后查看支撑论文

选择关键词后,右侧展示对应论文。图谱的价值最终要落在这些可阅读的材料上,节点运动只是辅助探索,不能代替论文联动。

5.3 看热度怎样逐年变化

动图:2021—2026 三大顶会的热度变化

这是从实际网站记录的六年播放过程。CVPR、ICCV、ECCV 固定使用蓝、橙红、绿,年份推进时对比条和历史轨迹一起变化。可以暂停、重播、调整速度,也可以手动选择年份。未举办的年份显示“—”,不会被当成零篇。

图 6:趋势页的年度对比与历史轨迹

“论文数”看绝对数量,“占比”看该词在同会议同年已收录样本中的比例。会议本身规模变大时,这两个指标可能给出不同的观察角度。

5.4 获取和管理自己的论文

图 7:论文列表与查询

论文库支持标题精确查询、编号与关键词等模糊查询,以及会议和年份筛选。列表中的论文可以查看、编辑和删除。

图 8:新增论文表单

手动录入时填写标题、会议、年份、摘要和原文地址;关键词可编辑。表单会检查必填项与会议年份,保存后的数据参与后续分析。

图 9:单篇和批量论文导入

可以输入一篇题目,也可以粘贴多行清单或导入 TXT/CSV。获取之后先核对候选,再确认入库;成功、重复和失败分别反馈。

图 10:论文详情与官方原文入口

详情页保留摘要、关键词、会议年份和来源。用户可以从统计回到单篇论文,再跳转官方原文,核对自己感兴趣的内容。

本地没有找到论文时,还可以继续联网获取。这里会区分“本地真正没有”与“被当前筛选隐藏”,避免不必要的外网查询。

我曾专门追问“关键词有没有入库、总数量是不是写死的”。复核时,隔离测试库删除一篇后从 29,048 变成 29,047,重新获取并导入后恢复;刷新后结果仍保留,重复导入不会增加数量,SQLite 中的关键词与接口一致。

5.5 了解数据来自哪里

图 11:数据来源与统计口径

数据页说明来源、范围、关键词方法和覆盖情况。本次目录共 29,051 条,取得 29,048 篇有效论文,3 条原站失效;覆盖 2021—2026 年 12 个举办届次。这个数量描述的是本次采集结果,不等于对所有录用论文的完整覆盖。

年度 Top 10 播放与数据说明也对应课程提出的“年度热词演变”和“了解更多”扩展方向。

六、人机结对讨论过程:三个真实案例

案例 A:从顶部菜单改为可折叠侧栏

关键提示词与人工判断。 我先要求 AI 提出整套原型方案供我审核,不立即写入 Figma。初稿形成后,我认可纸墨风格,但指出顶部菜单更像官网导航,不适合需要频繁切换模块的数据分析页面。我要求改成可展开和收起的侧栏,同时保留已认可的颜色与内容层次。这是一次明确的“部分采纳”:保留视觉方向,拒绝原导航结构。

AI 输出与迭代。 Agent 解释了侧栏对模块定位和图谱可用宽度的作用,给出展开、收起两种状态:展开时显示图标和名称,收起后保留图标,并在悬停或键盘聚焦时提示;切换时保留筛选及当前方向。看到修改方案后,我回复“可以”,才让 AI 继续核对作业要求并写入 Figma。这体现了先审阅、再批准进入原型的过程,而非一次生成后直接使用。

早期设计讨论的原始 Agent 截图

我拒绝顶部菜单并讨论侧栏的原始 Agent 截图

原型结果:Figma 设计文件;页面与交互说明见设计规格。

案例 B:动态榜单与可探索图谱的功能返工

关键提示词与人工判断。 第一版上线后,我指出四个可直接观察到的问题:Top 10 没有逐年动图、侧栏展开不对齐且开合没有动画、页面过渡太少、点击热门方向像整页刷新。AI 修复后,我又指出侧栏开关放在底部不自然,图谱只是一张死板的网。我没有把“能点击”和“画出节点”当作功能达标,而是要求明确反馈、动态变化和点击关键词后能继续阅读相关论文。

AI 输出与迭代。 Agent 查出方向切换会重新请求整份分析并卸载页面内容,于是改成榜单年度排序动画、词条即时高亮和局部论文更新;侧栏统一图标与文字位置,并加入平滑开合。第二轮将开关移到上方,图谱从一次性坐标布局改为 D3 力导向网络,支持拖拽、缩放、平移、重新布局和点击节点查看论文。AI 没有只调整图谱外观,还保留了关键词筛选与论文联动。

采纳与验证。 这轮的验收依据是公网桌面及手机的实际交互:年度榜单能换位,方向点击不再卸载整页,图谱节点拖拽和论文列表能正常工作;当轮 11 项回归测试通过。之后我仍继续审阅热门方向与图谱的页面分工,要求移除热门方向页的重复图谱。对应的实现与验证记录见交互动效复核、力导向图谱复核。

我连续指出交互和图谱问题、AI 修改结果的原始 Agent 截图

案例 C:近三万篇论文后的加载性能问题

关键提示词与人工判断。 扩充论文数据后,我发现趋势对比和关键词图谱的“正在加载”持续很久,于是直接追问是否因为数据太大。我希望查清具体卡点,而不是把等待时间简单归因于数据规模。

AI 输出与修复。 Agent 检查后确认,近三万篇论文本身可以处理;真正的问题是每次请求重复计算全套统计,趋势页收到约 910 KB 的整包数据,页面还一次渲染了 13,610 个关键词选项。修复包括按页面返回所需统计、复用计算结果、关键词按输入搜索、增加缓存及超时提示。

验证与采纳依据。 这次修复留下了可核对的优化前后指标:趋势响应约 910 KB → 3.2 KB,图谱约 910 KB → 17 KB;公网实测图谱请求约 289 毫秒,切换趋势主题约 229 毫秒。AI 还验证了低频关键词仍可检索、数据没有因裁剪响应而丢失,19 项测试通过。首次打开仍可能受到网络和冷启动影响,因此不能把一次毫秒级测量写成对所有访问的保证。详细口径见性能复核。

我报告加载缓慢、AI 解释原因和量化修复结果的原始 Agent 截图

贡献与可解释性声明

这些案例中的代码主要由 Agent 完成,我负责需求、方案取舍、交互审阅、问题反馈和验收。下面的代码说明按照实际源码整理,重点解释输入、处理过程和边界条件。对 AI 生成的部分,我同样需要理解,才能判断它是否真的解决了问题。

七、设计实现过程与统计口径

7.1 功能结构与技术架构

Waves 功能结构图

Waves 技术架构与数据流

前端使用 React 19、TypeScript 和 Vite;图表使用 ECharts,网络布局使用 D3。后端使用 Fastify 和 Node 24 内置 SQLite,Cheerio 解析官方页面。源码按路由、采集、存储、关键词、分析模块划分,减少接口与统计逻辑混在一起的情况。

共享基础论文为只读样本,个人增改删写入由 cookie 标识的覆盖层。读取时先应用覆盖和删除标记,再按规范化标题去重。因此一个访客的操作不会删除其他人的基础论文。这个设计适合无需注册的课程演示,但没有账号体系,清除 cookie 或换浏览器不能自动找回原个人库,也不提供跨设备同步。

7.2 数据获取、关键词与热度定义

数据来自 CVF Open Access、ECVA 和会议官方公开元数据。采集先保存目录,再取得详情中的标题、摘要、作者和原文链接;ECCV 2026 使用官方主会程序元数据。去重依据规范化标题,保留来源与获取时间。失效来源保留失败记录,不生成替代论文。

本项目没有使用 TF-IDF 或大模型在线推断关键词。实际方法是 topic-dictionary-v2-title-fallback:使用 28 类主题及别名匹配标题与摘要;未命中时从标题过滤停用词后保留短语。它便于解释和统一别名,但可能漏掉新术语,也可能因词典边界产生误分类。自动提取结果不冒充作者提供的原始关键词,用户可以编辑修正。

统计定义如下:

  • 热词篇数:包含该词的论文数;一篇论文内重复出现只计一次。
  • 样本占比:同会议、同年份中包含该词的论文数 ÷ 该范围有效论文总数 × 100%。
  • 共现权重:同时包含两个不同关键词的论文数,不代表引用、相似度或因果。
  • 缺失:会议未举办或没有可用样本时保留空值,曲线不跨缺失年份连线。

7.3 数据增长后为什么会慢

早期通用分析接口把多个页面的数据打包返回,还会重复计算统计;前端一次创建过多关键词选项。论文扩到近三万篇后,这些问题明显暴露。修改后按 explore、graph、trends 返回当前视图需要的内容,按会议/年份复用聚合索引,关键词只返回有限搜索结果。

该轮验证记录中,趋势响应由约 910 KB 降至约 3.2 KB,图谱响应约 17 KB;公网图谱请求约 289 毫秒,切换趋势主题约 229 毫秒。这里是指定验证环境下的测量结果,不是对所有访问速度的承诺。数据写入后必须使缓存失效,否则响应再快也可能显示旧统计。

八、约 300 行关键代码与解释

以下从项目实际源码摘录,共 300 行,保留源码内容;它们依赖项目其他类型、词典与组件,并非四个可以分别独立运行的示例。代码主要由 Agent 编写,解释按输入、处理、输出和边界组织。

8.1 关键词规范化与标题兜底

源码:apps/api/src/lib/keywords.ts,第 110—148 行(39 行)。

输入标题和摘要,先统一大小写、Unicode 与标点,再匹配词典别名;未匹配时按停用词分割标题,最多保留三个短语。词典使用统一主题名,避免同一方向以多个别名分散计数。限制在于规则方法无法理解完整语义,因此需要保留提取方法并支持人工修正。

export function normalize(s: string) {
  return s
    .normalize('NFKC')
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, ' ')
    .trim();
}
export function extractKeywords(title: string, abstract = '') {
  const text = ` ${normalize(title + ' ' + abstract)} `;
  const matched = Object.entries(TOPICS)
    .filter(([, aliases]) =>
      aliases.some((alias) => text.includes(` ${normalize(alias)} `)),
    )
    .map(([name]) => name);
  if (matched.length) return matched;
  // Unmatched papers retain transparent title phrases rather than an empty keyword field.
  // These are extracted phrases, not author-provided keywords or inferred research categories.
  const stop = new Set(
    'a an the of for with and or in on to from by via using towards toward through based learning learn new novel beyond is are as at into under'.split(
      ' ',
    ),
  );
  const chunks: string[][] = [[]];
  const titleWords = normalize(title.replace(/^[^:]{1,25}:\s*/, '')).split(' ');
  for (const word of titleWords) {
    if (stop.has(word)) {
      if (chunks[chunks.length - 1]!.length) chunks.push([]);
    } else chunks[chunks.length - 1]!.push(word);
  }
  const phrases = chunks
    .filter((words) => words.length >= 2)
    .map((words) => words.slice(0, 4).join(' '));
  const fallback = phrases.length
    ? phrases
    : titleWords.filter((w) => !stop.has(w)).slice(0, 3);
  return [...new Set(fallback)]
    .slice(0, 3)
    .map((s) => s.charAt(0).toUpperCase() + s.slice(1));
}

8.2 聚合缓存、Top 10、共现与趋势

源码:apps/api/src/lib/analytics.ts,第 1—213 行(213 行)。

group 用 Set 保证同篇同词只计一次,并按篇数排序。index 以有效论文数组快照为弱引用键,复用会议与年份分组;range 对筛选范围缓存并限制数量。summarize 仅在图谱视图统计显示节点之间的真实共现,保留所选低频词;其他视图无需承担这一计算和传输。analyze 将未举办与未采集状态分别标记,比例分母取同会议同年样本,最后为热门方向返回逐年榜单。缓存正确性的前提是存储层在数据变化后返回新快照。

import { conferences, type Paper } from './store.js';
export type AnalysisView = 'all' | 'explore' | 'graph' | 'trends';
type Row = {
  name: string;
  count: number;
  byConference: Record<string, number>;
};
type Group = { papers: Paper[]; ranking: Row[]; counts: Map<string, Row> };
// Store returns the same immutable snapshot until a library changes. Weak keys
// release old indexes after edits or an external data publication.
const indexes = new WeakMap<
  Paper[],
  { groups: Map<string, Group>; ranges: Map<string, Group> }
>();
function group(papers: Paper[]): Group {
  const counts = new Map<string, Row>();
  for (const p of papers)
    for (const keyword of new Set(p.keywords)) {
      let row = counts.get(keyword);
      if (!row) {
        row = {
          name: keyword,
          count: 0,
          byConference: { CVPR: 0, ICCV: 0, ECCV: 0 },
        };
        counts.set(keyword, row);
      }
      row.count++;
      row.byConference[p.conference] =
        (row.byConference[p.conference] ?? 0) + 1;
    }
  return {
    papers,
    counts,
    ranking: [...counts.values()].sort(
      (a, b) => b.count - a.count || a.name.localeCompare(b.name),
    ),
  };
}
function index(papers: Paper[]) {
  let cached = indexes.get(papers);
  if (!cached) {
    const buckets = new Map<string, Paper[]>();
    for (const p of papers) {
      const key = `${p.conference}:${p.year}`;
      if (!buckets.has(key)) buckets.set(key, []);
      buckets.get(key)!.push(p);
    }
    cached = {
      groups: new Map([...buckets].map(([key, rows]) => [key, group(rows)])),
      ranges: new Map(),
    };
    indexes.set(papers, cached);
  }
  return cached;
}
function range(papers: Paper[], conf: string, from: number, to: number) {
  const cache = index(papers).ranges;
  const key = `${conf}:${from}:${to}`;
  let result = cache.get(key);
  if (!result) {
    result = group(
      papers.filter(
        (p) =>
          (!conf || p.conference === conf) && p.year >= from && p.year <= to,
      ),
    );
    if (cache.size >= 32) cache.delete(cache.keys().next().value!);
    cache.set(key, result);
  }
  return result;
}
export function searchTopics(
  papers: Paper[],
  conf = '',
  from = 2021,
  to = 2026,
  query = '',
) {
  const term = query.trim().toLocaleLowerCase();
  return range(papers, conf, from, to)
    .ranking.filter((r) => r.name.toLocaleLowerCase().includes(term))
    .slice(0, 40)
    .map((r) => r.name);
}
function summarize(
  papers: Paper[],
  conf = '',
  from = 2021,
  to = 2026,
  topic = '',
  minEdge = 1,
  view: AnalysisView = 'all',
) {
  const { papers: filtered, counts, ranking } = range(papers, conf, from, to);
  const selected = counts.has(topic) ? topic : ranking[0]?.name || '';
  const needsGraph = view === 'graph' || view === 'all';
  const nodes = needsGraph ? ranking.slice(0, 20) : [];
  if (
    needsGraph &&
    counts.has(selected) &&
    !nodes.some((n) => n.name === selected)
  )
    nodes.push(counts.get(selected)!);
  const nodeNames = new Set(nodes.map((n) => n.name));
  const pairs = new Map<
    string,
    { source: string; target: string; count: number }
  >();
  if (needsGraph)
    for (const p of filtered) {
      // Only pairs whose endpoints are displayed can contribute to this graph.
      const keywords = [...new Set(p.keywords)]
        .filter((k) => nodeNames.has(k))
        .sort();
      for (let i = 0; i < keywords.length; i++)
        for (let j = i + 1; j < keywords.length; j++) {
          const source = keywords[i]!,
            target = keywords[j]!,
            key = JSON.stringify([source, target]);
          const pair = pairs.get(key) ?? { source, target, count: 0 };
          pair.count++;
          pairs.set(key, pair);
        }
    }
  const edges = [...pairs.values()]
    .filter((e) => e.count >= minEdge)
    .sort(
      (a, b) =>
        b.count - a.count ||
        a.source.localeCompare(b.source) ||
        a.target.localeCompare(b.target),
    );
  const topics = (view === 'all' ? ranking : ranking.slice(0, 40)).map(
    (r) => r.name,
  );
  if (selected && !topics.includes(selected)) topics.push(selected);
  return {
    total: filtered.length,
    ranking: ranking.slice(0, 10),
    topics,
    selected,
    graph: nodes,
    edges,
    keywordTotal: ranking.length,
    related:
      view === 'all'
        ? filtered.filter((p) => p.keywords.includes(selected)).slice(0, 10)
        : [],
    relatedTotal: counts.get(selected)?.count ?? 0,
  };
}
export function analyze(
  papers: Paper[],
  conf = '',
  from = 2021,
  to = 2026,
  topic = '',
  minEdge = 1,
  view: AnalysisView = 'all',
) {
  const summary = summarize(papers, conf, from, to, topic, minEdge, view);
  const selected = summary.selected;
  const trends =
    view === 'trends' || view === 'all'
      ? conferences.map((conference) => ({
          conference,
          points: Array.from({ length: to - from + 1 }, (_, i) => {
            const year = from + i;
            const held =
              conference === 'CVPR' ||
              (conference === 'ICCV' ? year % 2 === 1 : year % 2 === 0);
            const g = index(papers).groups.get(`${conference}:${year}`);
            const total = g?.papers.length ?? 0,
              count = g?.counts.get(selected)?.count ?? 0;
            return {
              year,
              total,
              count: held && total ? count : null,
              ratio:
                held && total ? Math.round((count / total) * 1000) / 10 : null,
              status: !held ? 'not-held' : total ? 'sample' : 'missing',
            };
          }),
        }))
      : [];
  const annual =
    view === 'explore' || view === 'all'
      ? Array.from({ length: to - from + 1 }, (_, i) => {
          const year = from + i;
          const {
            total,
            ranking,
            topics,
            selected,
            graph,
            edges,
            keywordTotal,
          } = summarize(papers, conf, year, year, '', minEdge, view);
          return {
            year,
            total,
            ranking,
            topics: view === 'all' ? topics : ranking.map((r) => r.name),
            selected,
            graph,
            edges,
            keywordTotal,
          };
        })
      : [];
  return { ...summary, trends, annual };
}

8.3 SQLite 保存、去重与个人删除标记

源码:apps/api/src/lib/store.ts,第 119—154 行(36 行)。

save 使用参数化 SQL 保存完整论文 JSON,keywords 一起持久化,同时使对应缓存失效。create 按规范化标题检测重复并返回 409。delete 写入当前 scope 的删除标记,而不是直接移除共享基础论文;读取时合并覆盖层才能得到用户实际看到的列表和总量。

  save(scope: string, paper: Paper) {
    if (scope === 'seed') {
      this.baseCache = undefined;
      this.personalCache.clear();
    } else this.personalCache.delete(scope);
    this.db
      .prepare(
        'INSERT INTO papers(scope,id,title_key,data,deleted) VALUES (?,?,?,?,0) ON CONFLICT(scope,id) DO UPDATE SET title_key=excluded.title_key,data=excluded.data,deleted=0',
      )
      .run(scope, paper.id, normalize(paper.title), JSON.stringify(paper));
    return paper;
  }
  create(scope: string, fields: Omit<Paper, 'id' | 'updatedAt'>) {
    if (
      this.papers(scope).some(
        (p) => normalize(p.title) === normalize(fields.title),
      )
    )
      throw Object.assign(new Error('论文已存在'), { statusCode: 409 });
    return this.save(scope, {
      ...fields,
      id: randomUUID(),
      updatedAt: new Date().toISOString(),
    });
  }
  delete(scope: string, p: Paper) {
    if (scope === 'seed') {
      this.baseCache = undefined;
      this.personalCache.clear();
    } else this.personalCache.delete(scope);
    this.db
      .prepare(
        'INSERT INTO papers(scope,id,title_key,data,deleted) VALUES (?,?,?,?,1) ON CONFLICT(scope,id) DO UPDATE SET deleted=1',
      )
      .run(scope, p.id, normalize(p.title), JSON.stringify(p));
  }

8.4 趋势播放的时间推进

源码:apps/web/src/Trends.tsx,第 51—62 行(12 行)。

播放时每隔 speed 毫秒推进一年;加载中或出错时不推进,到终点自动暂停。effect 清理旧计时器,避免多次操作后重复递增。year 同时驱动年度条形与轨迹的数据截断,ECharts 的更新动画与 CSS 条宽过渡呈现运动。停止计时、固定会议颜色和缺失值处理共同保证“动”不会改变统计含义。

  useEffect(() => {
    if (!playing || loading || error) return;
    if (year >= filters.to) {
      setPlaying(false);
      return;
    }
    const timer = setTimeout(
      () => setYear((y) => Math.min(filters.to, y + 1)),
      speed,
    );
    return () => clearTimeout(timer);
  }, [playing, year, filters.to, speed, loading, error]);

九、测试、Git 与部署

自动化回归记录为 19 项测试通过;类型检查和构建通过。测试之外,还通过真实浏览器核对桌面和移动端的播放、图谱、查询及导入流程。验证记录保存在源码仓库的 docs/verification/。

验证对象检查内容与结果
论文管理新增、修改、删除、重复导入与刷新持久化;关键词保存到 SQLite
查询标题精确、编号/关键词模糊;筛选空结果不误触发真正未命中逻辑
图谱共现边来源、节点筛选、拖拽缩放、点击后论文列表
趋势六年播放到终点后暂停、重播、固定会议色、缺失年份不填零
性能视图专用响应、缓存与低频关键词搜索,不以删数据换速度
公网应用首页 HTTP 200,/api/health 返回 status: ok

仓库已超过课程要求的 15 次提交,具体数量可在 CodeArts 提交历史中查看。dev 与 main 包含合并历史差异,不能将两个分支的次数直接相加。提交按环境搭建、业务功能、交互返工、数据扩容、性能修复和证据整理等实际工作拆分,未为凑次数制造空提交。开发分支已合并至 main,包含最新的同屏总览首页。远端保留 1.0.0 基础版本标签,已实际下载并检查该版本的 ZIP 源码归档;首页等后续更新以 main 为准。

公网运行于 RainYun 服务器:Caddy 提供 HTTPS 与反向代理,Docker 内 Fastify 提供 API 和构建后的前端,SQLite 存放在持久化目录中。班级通知允许使用其他云服务器,CodeArts 则用于源码托管。公网链接为 waves.cobaltmc.dev。

十、心路历程与收获

最明显的变化,是我不再把“AI 说完成了”当作验收。第一版看起来已经有几个页面,但我按作业逐项操作后仍觉得像毛坯房:图谱不容易探索,趋势缺少明显动画,热门方向点击像刷新。我重新列出核心功能,并提出具体问题,让 AI 的修改有明确目标。这比“再优化一下”更有效。

第二个收获是,设计审阅要检查连续操作。我认可纸墨配色,却反对顶部菜单;侧栏做出来后,我又发现按钮位置、栏目对齐和过渡动画不自然。单张截图看起来好看,并不说明操作流畅。以后我会把展开、收起、加载、空结果和错误状态也列入原型审阅。

第三个收获是,数据必须能够解释。我问过关键词之间的关系,也问过导入删除是否正确、关键词是否入库、论文总量是否写死。最后的复核需要同时查看界面、接口和存储,才能说明功能是真的工作。扩充数据之后的性能问题也让我看到,数量增加会暴露早期设计中重复计算和过量渲染的成本。

对照课程要求讨论的结对分工,人机结对中我更多承担需求和验收方向的判断,AI 更多承担实现和排查。但如果我不能解释代码,就不能只凭输出结果承担审查角色。下一次除了记录工时,我还会要求每个关键模块给出输入、输出、错误边界和一两个反例,自己理解后再进入下一步。

这份反思整理自我在开发中的真实反馈,并由我阅读确认。结合课程对结对编程的讨论,我更能体会到“实现”和“审查”需要相互配合:AI 可以快速尝试方案,但需求是否满足、结果是否可靠,仍需要人主动判断。

十一、对 AI 结对伙伴的评价

Agent 的贡献是能把需求较快落实到代码、原型、数据采集和验证脚本,并在我指出具体问题后持续修改。图谱改为 D3、六年数据补齐、接口按视图拆分,以及导入删除的持久化复核,都是协作中产生的实际成果。

它的局限也很明确:有时过早判断任务完成,容易把“页面存在”视为“功能达标”;需要反复提醒年份、动图和交互细节;对作业要求的解释还必须结合班级最新通知。数据来源、第三方接口和运行环境发生变化时,已有知识也不能代替现场核验。

与人人结对相比,人机结对可以迅速得到方案和实现,但 AI 不承担课程提交责任,也不能替我形成理解。我的作用不能停留在说“继续”,而应该是提出可验证的要求、指出不合理结果、决定取舍并检查证据。这次协作最有价值的部分,正是保留下来的多轮修正过程。

参考资料

...全文
101 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

88

社区成员

发帖
与我相关
我的任务
社区描述
计算机-软件工程
软件工程 高校 福建省·福州市
社区管理员
  • FZU_SE_LQF
  • *奈落*
  • 助教李烨
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧