022401402_陈材臻_软件工程第二次作业

022401402陈材臻 2026-09-24 22:32:57

作业基本信息

项目内容
这个作业属于哪个课程2601_CS_SE_FZU
这个作业要求在哪里软件工程实践第二次作业——与AI结对编程(顶会热词统计)
这个作业的目标与 AI 结对完成需求分析、原型设计、编码实现、测试与云部署,并如实记录人机协作过程
其他参考文献《构建之法》(NABCD 模型、两人合作);CVF Open Access;ECVA;OpenAlex API;DBLP API;Vue 3;ECharts 5;FastAPI;SQLite

姓名:陈材臻 学号:022401402
博客撰写时间:2026-09-24

目录

  • 作业基本信息
  • 1. Git 仓库、代码规范与 AI 工具说明
  • 1.1 仓库信息
  • 1.2 三处一致性核对(本地 → 远端)
  • 1.3 代码规范
  • 1.4 AI 编程助手说明
  • 2. PSP 表格
  • 3. NABCD 需求分析
  • N — Need(需求)
  • A — Approach(做法,重点)
  • B — Benefit(好处)
  • C — Competitors(竞争,重点)
  • D — Delivery(交付)
  • 4. 原型设计、信息架构与原型链接
  • 4.1 信息架构(IA)
  • 4.2 与 AI 协作设计的信息架构(要点)
  • 4.3 原型工具与链接
  • 5. 系统设计与功能结构
  • 5.1 技术栈(依据真实文件)
  • 5.2 功能结构图
  • 5.3 数据流
  • 6. 数据来源与统计口径
  • 6.1 数据来源(实测统计)
  • 6.2 统计口径(写清楚,避免误读)
  • 7. 部署与测试实录
  • 7.1 云服务器信息(实测)
  • 7.2 部署方式(不依赖 Docker)
  • 7.3 公网验证结果(本机 → 公网,18/18 全部 200)
  • 7.4 浏览器实测(控制台与渲染)
  • 7.5 数据完整性问题与解决(真实事件)
  • 7.6 部署期的 SSH 故障排查(完整过程)
  • 7.7 成品展示(公网实拍 11 张)
  • 8. AI 结对协作过程与代表性案例
  • 案例 A:用 AI 辅助需求分析与原型设计
  • 案例 B:用 AI 辅助编码实现(多源采集适配 + SQLite 数据链)
  • 案例 C:用 AI 辅助调试与修复 Bug(两个真实 Bug)
  • C-1 批量导入"成功数量"统计错误(业务代码 Bug)
  • C-2 部署脚本 SHA256 误判(工程脚本 Bug,且"校验机制正确拦停")
  • 附加案例(不计入三个核心案例):代理接管导致 CLI 502 的工具链故障
  • 9. 代码说明与关键代码
  • 9.1 约 334 行真实关键代码与逐段解释
  • 9.1.1 SQLite 去重 upsert 与逐字段择优合并
  • backend/db.py:52-81
  • 9.1.2 多源数据适配与错误包装
  • backend/sources.py:5-33
  • backend/sources.py:35-43
  • backend/sources.py:46-59
  • 9.1.3 关键词统计、方向分类、共现图谱与相似论文
  • backend/analysis.py:19-42
  • backend/analysis.py:60-66
  • 9.1.4 FastAPI 关键路由与参数校验
  • backend/main.py:19-37
  • backend/main.py:57-70
  • backend/main.py:91-108
  • 9.1.5 前端 OpenAlex 规范化、保存、批量导入与图表渲染
  • app.js:281-319 — reconstructAbstract / normalizeOpenAlex
  • app.js:342-373 — saveRemote / batchImport
  • app.js:384-428 — Top10 与关键词图谱
  • app.js:459-512 — 热度走势动画
  • 9.2 可解释性声明
  • 10. 提交前全项自检
  • 11. 心路历程、收获与反思
  • 11.1 人机结对 vs 传统人人结对
  • 11.2 三个具体教训
  • 11.3 对 AI 的评价
  • 11.4 如果重做一次
  • 附录:本文档事实性内容的复现命令


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

1.1 仓库信息

  • CodeArts 仓库地址:https://codehub.devcloud.cn-north-4.huaweicloud.com/cff1c4c3f719403da20397d8821bb8f4/visionpulse-topconf-hotwords.git
  • 仓库公开性:已实测匿名可读(禁用凭据助手后 git ls-remote 仍成功返回全部引用)
  • 稳定分支:main;开发分支:dev
  • 提交数:17 个 commit(其中 14 个非 merge、3 个 merge),满足"至少 15 次 commit"的要求
  • Tag / Release 版本:1.0.0、2.0.0、2.0.1、2.1.0
  • HEAD:848f788 fix: hydrate all cached papers in paged batches
  • 分支状态:dev 与 main 当前指向同一个提交 848f788(即 dev 的全部工作已合并回 main)

1.2 三处一致性核对(本地 → 远端)

推送到 CodeArts 后,我做了独立 mirror clone 与匿名读取双重核对,不是只看本地:

引用本地 SHA远端 SHA结论
main848f78893eb9848f78893eb9MATCH
dev848f78893eb9848f78893eb9MATCH
tag 1.0.08f041133f17f8f041133f17fMATCH
tag 2.0.0eaac788bbc3eeaac788bbc3eMATCH
tag 2.0.1ee6577b6a67dee6577b6a67dMATCH
tag 2.1.047acf497473d47acf497473dMATCH
README.md blobd1b5adca68fdd1b5adca68fdMATCH(字节级一致,不会乱码)

远端 commit 统计与本地一致:rev-list --count main = 17,--no-merges = 14;远端 3 个 merge commit 为 078aa2f merge: release phase two data pipeline、95675ce merge: polish deployment assets、3452331 merge: stabilize test command。

没有 squash、没有 rebase、没有改写历史、没有伪造时间戳——所有远端 SHA 都能与本地逐一对应。

1.3 代码规范

代码规范见 codestyle.md,来源已在文件顶部标注(主流官方/大厂推荐规范)。规范覆盖命名、提交信息格式、目录组织、错误处理与注释要求。该网页直链能否匿名打开取决于 CodeArts 仓库权限。

1.4 AI 编程助手说明

项目内容
使用的 AI 工具Codex(OpenAI)/ ChatGPT
主要用途需求拆解与信息架构、多源数据适配方案设计、代码审查、Bug 定位、测试用例设计、交付文档整理
不做的事不直接提交未读懂的 AI 代码;不伪造任何对话或提交历史

可解释性声明:AI 参与了方案初稿、样板代码与调试建议的生成;所有接口调用、数据结构、统计口径、边界处理、部署脚本与本文档的事实性内容,均由本人逐项审查、运行验证并确认。凡是 AI 给出而被我拒绝的方案,均在 第 8 节记录拒绝理由。


2. PSP 表格

PSP 阶段任务预估/min实际/min
Planning阅读作业要求、拆分为可验收清单3045
Development / AnalysisNABCD 需求分析、技术选型、数据口径确定6075
Development / Design Spec页面结构与交互设计(信息架构、原型)4555
Development / CodingVue 前端、论文管理、查询、图表、导入导出180210
Development / Coding(阶段二)多源采集适配、SQLite 数据链、分析与部署资产150185
Development / Code Review代码审查、边界与失败分支修正4550
Development / Test后端测试、构建检查、浏览器功能验证6070
DeploymentCodeArts 推送与核对、云服务器部署与公网验证90130
ReportingREADME、博客、验收清单90120
合计750940

偏差分析(如实):

  1. 部署阶段超出最多(+40 min)。主要耗时不在脚本,而在排查公网 SSH 连不上:服务器内 sshd 实测 active/enabled/22 端口 LISTEN,但公网连接只能建立 TCP、读不到 SSH banner。最终定位为**安全组 sg-default-smb 的入方向规则缺少"IPv4 / TCP / 22 / 0.0.0.0/0"**(原有规则只有"组内互通")。这是一次典型的"分层排查"——把"能连上"和"能通信"分开看才找到根因。
  2. Reporting 超出(+30 min)。因为坚持"零臆造",所有数字都要跑命令实测并复算,不能凭印象写。
  3. 阶段二编码超出(+35 min)。多源适配的真实难点在数据形态差异(OpenAlex 摘要倒排索引、CVF 列表页只有标题链接、ECVA 单页多分区、DBLP 反爬),不是写 CRUD。
  4. 需求拆解节省了后期返工:先按评分项建清单,避免了"写完才发现漏项"。

3. NABCD 需求分析

N — Need(需求)

小刚是计算机视觉初学者,想了解 CVPR / ICCV / ECCV 的研究热点。但三大顶会论文量极大(以 CVPR 为例,2025 年投稿 13008 篇、录取 2878 篇),逐篇阅读总结几乎不可行。他需要的不是一个"更强的搜索引擎",而是把"热点发现"这件事自动化:能看到 Top 10 方向、能看到方向之间的关系、能看到热度随年份与会议的走势,并且能从热点一键下钻到具体论文。

A — Approach(做法,重点)

核心判断:不做"单一检索工具",做"从热点发现到论文定位"的闭环。

  • 统一论文模型:把 OpenAlex / CVF Open Access / ECVA / DBLP 四种完全不同形态的数据,归一为 title / authors / conference / year / abstract / keywords / doi / paper_url / pdf_url / source 十个字段。没有统一模型就不可能有可解释的统计。
  • 真实数据链,而非演示数据:真实网络采集 → 多源适配 → 清洗去重 → SQLite 缓存 → 统计分析 → 可视化探索,六个环节都有实例数据。
  • 去重策略是数据可信度的地基:标题规范化后建唯一键,DOI 另建部分唯一索引(允许空 DOI),冲突时逐字段择优合并(ON CONFLICT ... DO UPDATE 配合 CASE WHEN excluded.x <> '' / COALESCE),保证"第二次抓到更穷的记录不会把第一次的有效字段冲成空"。
  • 失败要可解释:DBLP 反爬、OpenAlex 429 都是预期内的失败。接口在这些情况下返回带可读原因的 502 并记录采集任务,而不是吞异常让页面崩掉或假装成功。
  • 口径要写清:平台在"关于"页公开数据来源与关键词统计口径,避免用户把词频当权威结论。

B — Benefit(好处)

用户路径从"检索→逐篇读→自己总结"变成"看 Top 10 → 点关键词看关系 → 下钻到论文 → 看趋势判断方向升降"。对课程场景,它把"论文管理 + 统计分析 + 趋势对比"放到同一个可解释界面里,而不是分散在检索站与表格工具之间。

C — Competitors(竞争,重点)

竞品强在哪本项目如何与之差异
DBLP数据规模大、检索权威不拼规模。本平台只聚焦 CVPR/ICCV/ECCV 三会,并且把"统计口径 + 数据来源"直接写在页面上
Google Scholar覆盖面最广、引用数据强不提供引用分析,换取向"热点聚合 + 关系图谱 + 年份走势"的可解释可视化
OpenAlex 官网开放 API、字段丰富面向研究者的原始数据检索,非面向初学者的"热点结论"
Connected Papers关系图做得成熟它以单篇论文为中心;本项目以会议 + 关键词 + 年份为轴心,服务"领域热点"而非"单篇引文"
三会官方站点(CVF/ECVA)数据权威只有会议论文列表,没有统计与可视化。本平台把它作为数据源,补上分析与呈现层

差异化优势:① 三会聚焦 + 口径透明;② 从"热点"到"论文"的可点击闭环;③ 数据来源字段可溯源(每篇论文都能看到来自 CVF / ECVA / OpenAlex / DBLP 的哪一处);④ 采集失败可解释,不制造"看起来有数据"的假象。

D — Delivery(交付)

本地可运行项目、专用原型工具只读分享链接、CodeArts 仓库、云服务器公网地址、本博客。所有外部地址均为实测可访问的真实地址,未完成的项在 第 10 节明确标注为 PARTIAL/MISSING,不虚构。


4. 原型设计、信息架构与原型链接

4.1 信息架构(IA)

五个一级页面 + 三条跨页联动:

主页(热门方向总览)
 ├─ 4 个统计卡片(论文总量 / 会议覆盖 / 年份范围 / 关键词数)
 ├─ Top 10 热门方向(柱状图)
 └─ 关键词图谱(力导向图)──点击关键词──▶ 相关论文列表

热度走势对比页
 └─ CVPR / ICCV / ECCV 多年份折线图 + 播放/暂停动画

年度热词演变页
 └─ 历年 Top 10 榜单演变

方向探索页
 └─ 可解释方向分组 → 代表论文

论文管理页
 ├─ 模糊查询 / 题目精确查询
 ├─ 论文表格(编号/题目/会议/年份/关键词/操作)
 ├─ 增 / 删 / 改 / 详情
 └─ 本地未命中 ──▶ 联网检索结果(可保存入库)

爬取 / 导入页
 ├─ 单篇题目联网检索
 └─ 批量文本导入(单次上限 10 行)

关于页
 └─ 数据来源、统计口径、数据流、技术栈

4.2 与 AI 协作设计的信息架构(要点)

  • AI 给出的初稿包含较多"为了显得功能多"的入口。人工裁剪:删掉与五项基础功能无关的入口,避免界面噪音与评分点分散。
  • 人工增补了题目明确要求但初稿漏掉的状态:空数据状态、联网失败状态、加载中状态。
  • 人工确认每个一级页面都能映射到评分点(功能 1~5 + 两个扩展功能),不能映射的不做。

4.3 原型工具与链接

img

https://modao.cc/proto/design/pb2muforp4p9uwxm8?open_access_menu=


5. 系统设计与功能结构

5.1 技术栈(依据真实文件)

层选型依据
前端Vue 3 + ECharts 5(原生单页,无构建产物)index.html / app.js / style.css
后端FastAPI(Python 3.13)+ Pydantic 校验backend/main.py
存储SQLite(单文件,零外部服务)backend/db.py
采集Python 标准库 urllib + 正则解析backend/sources.py
分析自实现关键词统计 / 方向分类 / 相似度backend/analysis.py
部署systemd + nginx 反向代理(不依赖 Docker)visionpulse.service / nginx-visionpulse.conf
可访问性高对比与 prefers-reduced-motion 支持accessibility.css

为什么后端上 FastAPI + SQLite:单文件持久化、零外部服务、迁移即拷贝文件、本地与云上行为一致,把部署变量降到最低。为什么前端不用构建式框架:单页零构建,nginx 直接托管静态资源,减少云上部署环节。

5.2 功能结构图

代码级细节(模块 → 文件/函数映射、API 清单、数据库 DDL、ECharts 位置、约 300 行关键代码)见 submission/audit/code-audit.md 与 submission/audit/key-code.md,由基于真实文件的静态审计产出。

flowchart TD
  A[VisionPulse 平台] --> B[功能1 论文信息爬取]
  A --> C[功能2 论文列表管理]
  A --> D[功能3 Top10 热门方向]
  A --> E[功能4 关键词图谱]
  A --> F[功能5 热度走势对比]
  A --> G[扩展 年度热词演变]
  A --> H[扩展 方向探索]
  A --> I[扩展 相似论文推荐]

  B --> B1[单篇题目检索]
  B --> B2[批量导入 上限10行]
  B --> B3[来源适配 OpenAlex/CVF/ECVA/DBLP]

  C --> C1[增删改查]
  C --> C2[题目精确查询]
  C --> C3[模糊查询 编号/关键词]
  C --> C4[本地未命中转联网检索]
  C --> C5[CSV / JSON 导出]

  B3 --> S[(SQLite 缓存)]
  S --> D
  S --> E
  S --> F
  S --> G
  S --> H
  S --> I

5.3 数据流

flowchart LR
  CVF[CVF Open Access] --> ADP[多源适配层 sources.py]
  ECVA[ECVA] --> ADP
  OA[OpenAlex API] --> ADP
  DBLP[DBLP API fallback] --> ADP
  ADP --> CLEAN[清洗去重 db.py 标题规范化+DOI部分唯一索引+逐字段择优合并]
  CLEAN --> DB[(SQLite visionpulse.sqlite3)]
  DB --> ANA[统计分析 analysis.py]
  ANA --> VIZ[Vue3 + ECharts 可视化]

6. 数据来源与统计口径

6.1 数据来源(实测统计)

依据 data/visionpulse.sqlite3 实际查询结果:

维度实测值
论文总数320
会议分布CVPR 180 · ICCV 80 · ECCV 60
来源分布CVF Open Access 260 · ECVA 60
年份分布2023 = 160 · 2024 = 160
数据库大小245,760 B(约 240 KB)

获取方式:通过公开可访问的会议官方页面与开放 API 采集元数据——CVF Open Access(CVPR / ICCV 论文列表页)、ECVA(ECCV 论文页,按年份注释分区解析)、OpenAlex Works API(检索与摘要倒排索引)、DBLP 检索 API(作为 fallback)。

仅用于课程教学。采集脚本限制单次数量、不做高并发抓取,以降低对公开站点的压力。

6.2 统计口径(写清楚,避免误读)

  • 关键词来源:优先使用来源元数据中的关键词/主题字段;缺失时从标题抽取(正则取长度 ≥4 的英文词,过滤停用词如 with / from / using / for / and / the / via / based / learning / image / images / visual),每篇最多取 8 个。
  • 热度(Top 10 方向):按关键词在论文库中的出现频次聚合排序;方向归类基于可解释的关键词规则,而非黑盒模型。
  • 热度走势:统计指定会议、指定关键词在各年份的论文数量变化,用于对比 CVPR / ICCV / ECCV 的升降。
  • 年度热词:按年分别统计关键词排名,并展示排名变化与进出榜。
  • 相似论文:基于关键词集合的 Jaccard 相似度 + 文本 token 重合计算,输出 Top N。
  • 免责:上述指标均为本文库内的相对统计,不代表整个计算机视觉领域的绝对热度;词频受采集范围与关键词抽取规则影响。

7. 部署与测试实录

7.1 云服务器信息(实测)

项实测值
云服务华为云 Flexus 应用服务器 L 实例
区域华北-北京四
公网地址http://119.3.251.115/
规格2 vCPU / 2 GiB
系统盘40 GiB SSD
操作系统Ubuntu 22.04 server 64bit
公网能力弹性公网 IP · 峰值带宽 2 Mbps · 流量包 200 GB/月
计费包年 / 包月,1 个月,未开启自动续费
月费¥41.62(无数据盘 / HSS / CBR 等额外付费服务)
到期2026-10-24

选型理由:作业要求"部署到华为云 CodeArts/云服务器"。对比后,纯 ECS 路线最低档 1C1G 为 ¥58.60/月且不含带宽;Flexus L 套餐档 ¥41.62/月即含 2C2G + 40GiB 盘 + 2Mbps + 200GB 流量,规格更高、总价更低。运行 Vue 静态页 + FastAPI + SQLite + 320 条数据、低访问量,2C2G 有明显富余。

7.2 部署方式(不依赖 Docker)

上传 Git bundle → git clone 解包(校验 commit 与 tag)→ provision-ubuntu.sh 一键脚本:

apt 安装 python3-venv / python3-pip / nginx
→ 建 venv、装 backend/requirements.txt
→ 执行 scripts/seed_dataset.py 采集真实论文元数据入库
→ 安装 systemd 单元并 enable + restart
→ 安装 nginx 站点并配置反向代理
→ 应用内健康检查(校验 paperCount > 0)
  • 应用只监听 127.0.0.1:8000,公网仅开放 nginx 的 80 端口
  • 数据库固定持久化在 /opt/visionpulse/data/visionpulse.sqlite3,重跑部署不会删除数据目录
  • 脚本幂等:可安全重复执行

7.3 公网验证结果(本机 → 公网,18/18 全部 200)

路径状态字节耗时
/20018,231268 ms
/api/health2008794 ms
/api/papers?page=1&page_size=52003,292177 ms
/api/stats/overview200124181 ms
/app.js20028,173340 ms
/style.css2007,350189 ms
/accessibility.css200807182 ms
/favicon.svg200178202 ms
/api/stats/top-directions200534196 ms
/api/stats/keywords200924203 ms
/api/stats/graph2006,618219 ms
/api/stats/trend2001,375198 ms
/api/stats/annual-hotwords2001,529166 ms
/api/stats/directions20048,5541,970 ms
/api/ingest/jobs200646543 ms

关键返回:

/api/health         → {"status":"ok","database":"/opt/visionpulse/data/visionpulse.sqlite3","paperCount":320}
/api/stats/overview → {"papers":320,"keywords":1000,"conferences":["CVPR","ECCV","ICCV"],
                       "years":[2023,2024],"sources":["CVF Open Access","ECVA"]}
CVPR = 180 · ICCV = 80 · ECCV = 60
/api/papers 第一页 → count=320, pageSize=5, totalPages=64

7.4 浏览器实测(控制台与渲染)

项结果
页面标题VisionPulse - 计算机视觉顶会热词分析平台
readyStatecomplete
控制台消息[](零条)
页面 JS 报错0 条
是否白屏否 —— 2 个 ECharts canvas 真实绘制(1102×495)
主要区域Top 10 ✓ · 关键词图谱 ✓ · 热度走势 ✓ · 论文管理 ✓
首页文案「当前论文 320」「会议覆盖 3 CVPR/ICCV/ECCV」

7.5 数据完整性问题与解决(真实事件)

问题:云端服务器首次部署后,/api/health 正常、所有接口 200,但种子数据只有 200 篇(CVPR2024 100 篇 + ECCV2024 100 篇,ICCV2024 因 CVF 返回 404 被跳过)。原因是云服务器侧访问 CVF/ECVA 的网络路径不稳定。

解决:不重新联网抓取、不生成任何 mock 数据,而是把本地已完整验证过的真实数据库(320 篇)安全迁移到服务器:

  1. 上传前先记录线上健康状态
  2. 上传到临时路径后比对 SHA256(不一致立即中止、不覆盖线上库)
  3. 覆盖前 cp -a 备份线上库(带时间戳)
  4. install -o www-data -g www-data -m 0640 安装,保持权限与部署脚本一致
  5. systemctl restart visionpulse
  6. 复验:paperCount=320,CVPR/ICCV/ECCV = 180/80/60

过程中出现一次脚本自身缺陷:远端 SHA256 由 Paramiko 以 bytes 返回,与本地 str 比较导致误判不一致而安全中止(线上库未被覆盖)。修复方式是统一解码为 str 并校验 64 位小写 hex 格式,补了 9 个单元用例(含该 bytes 场景与 4 个非法输入拒绝用例),全部 PASS。这个缺陷被写进 第 8 节案例 C——**"校验机制正确地拦住了一次误覆盖"本身是正面结果**。

7.6 部署期的 SSH 故障排查(完整过程)

现象:服务器内部 systemctl status ssh = active (running)、is-enabled = enabled、0.0.0.0:22 与 [::]:22 均 LISTEN,但本机连不上。

分层取证:

测试结果
原生 TCP 连接 22✅ 能建立
裸 socket 读 banner❌ 0 字节(连续 3 次)
ssh-keyscan -T 10❌ Connection closed by remote host(多种密钥类型)
ssh -vvv(BatchMode)本机发出 SSH-2.0-OpenSSH_for_Windows_9.5 → ❌ kex_exchange_identification: Connection closed by remote host

根因:安全组 sg-default-smb 的入方向规则**只有"组内互通"(允许 IPv4 全部 / 源=sg-default-smb、允许 IPv6 全部 / 源=sg-default-smb 与一条拒绝 IPv6 全量的规则),没有任何"IPv4 / TCP / 22 / 0.0.0.0/0"**。所以云侧转发层接受 SYN、安全组却丢弃流量 → banner 永远读不到。

修复与验证:放通入方向 22(同时按需放通 80),随后本机实测:

banner bytes=42 text=[SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.14]
kex: algorithm: curve25519-sha256
kex: host key algorithm: ssh-ed25519
Server host key: ssh-ed25519 SHA256:Hg9ci9ju57pxFcB9s9FY0yN/z1YXm8tBa725Hp+3hRA
Authentications that can continue: publickey,password

重要教训(我自己犯的错,写下来):我一开始根据"22/80/443 端口都能 TCP 握手"就下结论说"安全组已放行"——这是过度解读。当时 80/443 那台机器上根本没有服务在听,却同样"握手成功",说明华为云的公网 IP 前面有一层转发,无论后端是否有服务都会先接受 TCP。所以"TCP 能连上"既不证明安全组放行、也不证明服务在跑。必须读到应用层响应(SSH banner / HTTP 200)才能下结论。


7.7 成品展示(公网实拍 11 张)

以下 11 张均来自公网真实运行站点 http://119.3.251.115/ 的实际页面截图,
由本机 Puppeteer + Chrome 抓取(视口 1600×1000、deviceScaleFactor 2),
抓取过程中监听到的 console error / pageerror = 0。每张图后附其证明的功能。

图 1|首页首屏:统计卡片(当前论文 320、会议覆盖 3 CVPR/ICCV/ECCV、年份范围、关键词数)与主导航

图 1 首页首屏:统计卡片(当前论文 320、会议覆盖 3 CVPR/ICCV/ECCV、年份范围、关键词数)与主导航

对应功能:平台整体与数据总览

图 2|首页下滚:Top 10 热门方向柱状图

图 2 首页下滚:Top 10 热门方向柱状图

对应功能:功能 3 Top10 热门方向

图 3|首页继续下滚:关键词图谱(力导向共现关系图)

图 3 首页继续下滚:关键词图谱(力导向共现关系图)

对应功能:功能 4 关键词图谱

图 4|热度走势对比页:CVPR/ICCV/ECCV 多年份折线图 + 播放控制

图 4 热度走势对比页:CVPR/ICCV/ECCV 多年份折线图 + 播放控制

对应功能:功能 5 热度走势对比(动画)

图 5|年度热词演变页:按年独立计算 Top 10 并展示排名变化

图 5 年度热词演变页:按年独立计算 Top 10 并展示排名变化

对应功能:扩展功能 年度热词演变

图 6|方向探索页:可解释方向分组与代表论文

图 6 方向探索页:可解释方向分组与代表论文

对应功能:扩展功能 方向探索

图 7|论文管理页:论文表格、查询区与导出入口

图 7 论文管理页:论文表格、查询区与导出入口

对应功能:功能 2 论文列表管理

图 8|在论文管理页输入关键词执行查询后的结果状态

图 8 在论文管理页输入关键词执行查询后的结果状态

对应功能:功能 2 精确/模糊查询

图 9|论文详情弹窗:含来源、DOI、原文链接等溯源信息

图 9 论文详情弹窗:含来源、DOI、原文链接等溯源信息

对应功能:功能 2 详情 + 数据可溯源

图 10|爬取 / 导入页:单篇题目检索与批量导入(单次上限 10 行)

图 10 爬取 / 导入页:单篇题目检索与批量导入(单次上限 10 行)

对应功能:功能 1 论文信息爬取

图 11|关于页:数据来源、关键词统计口径、数据流与技术栈说明

图 11 关于页:数据来源、关键词统计口径、数据流与技术栈说明

对应功能:统计口径透明化

8. AI 结对协作过程与代表性案例

案例 A:用 AI 辅助需求分析与原型设计

背景:基础功能已能跑(1.0.0,数据在 localStorage、只查 OpenAlex),但"数据从哪来"经不起追问——刷新换浏览器就丢数据、没有真实会议数据、没有来源溯源。

img

Prompt(摘要):请把作业要求拆成基础功能、原型页面、验收状态与博客材料,指出容易漏掉的硬性要求;并判断——要让五项功能变成"有真实数据来源"的系统,该补哪些数据源、用什么存储、后端要不要上?

AI 输出摘要:拆为五项基础功能 + NABCD + 专用原型 + 云部署 + 15 次提交 + dev 分支 + Release + PSP + 3 个协作案例;建议主数据源改为公开可获取的真实来源(CVF Open Access / ECVA / OpenAlex / DBLP),存储换 SQLite,后端上 FastAPI。

人工判断(关键取舍):

AI 建议我的决定理由
用老师提供的数据集作主来源不采纳静态数据集无法体现"采集→清洗→入库"链路;公开源可追溯、可复算
多接几个数据源"显得强"裁剪到 4 个其余要求账号或违反站点条款;数据源不是越多越好
上 PostgreSQL / Redis拒绝与作业规模、云上运维成本不匹配,SQLite 足够
前端换成构建式框架保留原生 + CDN单页零构建,云上只需 nginx 托管静态资源
扩展功能任意加只加 3 个能解释的必须能从现有字段算出,且能在页面讲清口径
直接用原型工具生成的代码拒绝作业明确"切勿用原型工具生成代码,一经发现直接 0 分"

验证结果:实测缓存 320 条真实元数据(CVPR 180 / ICCV 80 / ECCV 60,2023–2024),/api/health 返回 paperCount=320。

案例 B:用 AI 辅助编码实现(多源采集适配 + SQLite 数据链)

img

Prompt(摘要):为 4 个形态完全不同的来源设计统一适配层(统一 Paper 模型、来源归一化函数、可读错误、摘要倒排索引还原、会议判定只能落在 CVPR/ICCV/ECCV/OTHER),并设计 SQLite 去重策略——要求同一篇论文被第二次抓到时用更丰富的字段补充旧记录,而不是覆盖成空。

AI 输出摘要:给出 abstract_from_inverted() 位置排序还原摘要;infer_conference(source_name, title) 用来源名 + 标题双重判定;normalize_openalex() 合并 keywords/topics 与 landing_page/pdf 链接;正则解析 CVF/ECVA 列表页;异常统一包装为 SourceError;去重采用"标题规范化唯一键 + DOI 部分唯一索引 + ON CONFLICT DO UPDATE 逐字段择优"。

人工判断与修正:

  1. AI 初稿只按 conference 字段判定会议 → 人工改为**"来源名 + 标题"双重判定**,避免 CVF 列表页缺 venue 字段时全部落到 OTHER。
  2. AI 初稿 upsert 用 UPDATE ... SET 直接覆盖 → 人工改为逐字段择优合并,否则第二次抓取的空字段会把第一次的有效字段冲掉。
  3. AI 未考虑"DBLP 反爬 / OpenAlex 429 属预期失败" → 人工要求接口返回带可读原因的 502 并记录任务,而不是吞异常。
  4. 人工决定采集限量(单次 ≤300、批量导入 ≤10 行),不做高并发抓取。

验证结果:后端测试 5 passed,其中与本案例直接相关——

  • test_upsert_deduplicates_titles:"A Vision Model" 与 "A Vision Model" 归一化后为同一条记录;同 DOI 不同标题亦为同一条记录
  • test_source_normalization_and_fallback:infer_conference("unknown venue", "European Conference on Computer Vision paper") == "ECCV";摘要还原为 "models vision";monkeypatch 让 OpenAlex 抛 SourceError 后 /api/ingest/search 仍 200 且 source == "DBLP"
  • 实际运行:python -m pytest -p no:cacheprovider backend/tests -q → 5 passed in 0.72s

案例 C:用 AI 辅助调试与修复 Bug(两个真实 Bug)

img

img

C-1 批量导入"成功数量"统计错误(业务代码 Bug)

现象:批量导入多行标题时,列表实际只新增 1~2 篇(重复标题被跳过),页面却提示"成功加入 5 篇"。

AI 定位结论:根因在函数没有返回值——saveRemote() 判断重名后 return(无返回值 = undefined),而批量循环里无条件 ok++,于是"跳过"对计数不可见:尝试次数被当成了成功次数。AI 同时指出:不应该在 batchImport 里再写一遍重名判断(会出现两处去重规则,后续易不一致)。

人工采纳与修改(最小改动):让 saveRemote 返回布尔值,并增加 silent 参数避免批量时每行弹一次 Toast;调用方只认返回值 if (rs[0] && saveRemote(rs[0], true)) ok++。

验证结果:同一标题导入 3 次 → 列表仅新增 1 篇、计数 = 1;5 行中 3 行重复 → 计数 = 2 且与列表新增一致;回归 npm run build 通过。

反思:这次修复的价值不在改动量(5 行),而在单一去重入口——去重规则只有一处,计数由它的返回值派生。任何"过滤/跳过"分支都必须显式返回结果,否则调用方的统计必然失真;这类 Bug 不会被类型系统发现,只能靠手工构造重复输入的用例暴露。

C-2 部署脚本 SHA256 误判(工程脚本 Bug,且"校验机制正确拦停")

现象:数据库同步脚本在"上传后校验"这一步安全中止,报 SHA256 不一致——但打印出的两串摘要内容完全一致。

AI 定位结论:远端摘要由 Paramiko 以 bytes 返回(打印成 b'90fe…'),脚本却拿它和 hashlib 产生的 str 比较;Python 中 bytes != str 恒为真,所以内容一致也判不一致。校验机制本身按设计工作了(中止、未覆盖线上库),错的是喂给它的比对输入类型。

人工采纳与修正:① read() 内部统一解码为 str;② 新增 normalize_sha(),两侧统一成 64 位小写 hex 再比较,并校验长度与字符集(非法即报错中止);③ 顺带修掉同一 read() 被用于生成备份时间戳所导致的畸形备份文件名问题。

验证结果:修复后 py_compile exit=0、AST OK,并新增 9 个单元用例——bytes 输入(本次真凶)、str、带尾部文件名、大写规范化、空白换行 全部 PASS;长度不足、含非 hex、空字符串、非法 bytes 四个非法输入全部被正确拒绝。即"该通过的通过、该拒绝的拒绝"两边都验了,不是只测 happy path。

反思:一次"误报的不一致"比一次"漏报的一致"危险得多——如果当时为了赶进度直接跳过校验,就会失去唯一能防止"传错文件覆盖线上库"的保护。宁可误停,不可漏放。

附加案例(不计入三个核心案例):代理接管导致 CLI 502 的工具链故障

现象:Codex / ChatGPT Work 突然报 unexpected status 502 Bad Gateway,请求地址指向本机 http://127.0.0.1:3456/v1/responses。

根因:本机另一个路由类工具(Claude Code Router)在启动时执行了 global profile takeover,把全局 ~/.codex/config.toml 改写——顶部注入 model_provider = "claude-code-router" 与 model catalog,底部注入 [model_providers.claude-code-router](base_url = http://127.0.0.1:3456/v1)。**User / Machine 两级环境变量完全没有 OPENAI_* / ANTHROPIC_***——只看环境变量会得出"配置没问题"的错误结论,真正的注入写在配置文件里。

修复与验证:备份后外科式移除注入的 provider/base_url/model catalog 段(不做整文件回滚,避免丢近期设置),并从源头停用针对该工具的 takeover profile(保留代理服务、密钥与其他工具配置不变)。验证:codex doctor 显示 default model provider: openai、config parse ok,codex exec 实测返回 PONG;启动注入源服务后确认未再次注入(耐久性验证)。

顺带的坑:用 PowerShell 把 JSON 直接传给 curl.exe/Invoke-WebRequest 会被 shell 改写,导致本地 API 返回 502 {"error":{"message":"Expected property name or '}' in JSON at position 1"}}——那是测试脚手架自己坏了,不是服务故障。正确做法是把 body 写进文件再用 --data-binary "@file"。这条经验后来直接救了上面的部署脚本(也是"先怀疑自己的输入,再怀疑系统")。

为什么单列:这属于工具链环境排查,不是项目功能开发,因此不占用三个核心案例的名额。


9. 代码说明与关键代码

9.1 约 334 行真实关键代码与逐段解释

下面的代码均从仓库原文件按标注行号摘录,未改写、未用无意义代码凑行数。共约 334 行,覆盖 SQLite 去重合并、多源数据适配、关键词分析、FastAPI 路由、前端数据规范化、批量导入、Top 10、关键词图谱与热度走势动画。

共摘录约 330 行。所有代码块均从审计时仓库原文按标示行号切片,未改写;中文文字是审计解释,不属于源码。

9.1.1 SQLite 去重 upsert 与逐字段择优合并

backend/db.py:52-81

设计思路:以规范化题名 title_key 作为主冲突键,并在写入前用 DOI 查回已有题名键,把“同 DOI、题名略有差异”的记录归并到同一行。冲突时不是整行覆盖,而是只让有信息量的新值替代旧值。

为什么这么写:不同学术来源的字段完整度不一致;OpenAlex 可能有摘要/DOI,CVF 可能有更直接的页面链接。逐字段择优可在多次采集时渐进补全,避免空串、OTHER 或 null 冲掉已有数据。

边界处理:空标题直接抛错;字符串关键词按中英文逗号/分号切分;关键词缺失时从英文题名生成最多 8 个候选;DOI 为空时不走 DOI 合并。代码未更新冲突行的 title 字段,且纯中文题名规范化可能为空,这是可见边界。

def upsert_paper(paper: dict[str, Any]) -> dict[str, Any]:
    title = str(paper.get("title", "")).strip()
    if not title: raise ValueError("论文题目不能为空")
    title_key = normalize_title(title); keywords = paper.get("keywords") or []
    if isinstance(keywords, str): keywords = [x.strip() for x in re.split(r"[,,;;]", keywords) if x.strip()]
    if not keywords:
        stopwords = {"with", "from", "using", "for", "and", "the", "via", "based", "learning", "image", "images", "visual"}
        keywords = list(dict.fromkeys(word.lower() for word in re.findall(r"[A-Za-z][A-Za-z-]{3,}", title) if word.lower() not in stopwords))[:8]
    with connection() as conn:
        doi = str(paper.get("doi", "")).strip()
        if doi:
            same_doi = conn.execute("SELECT title_key FROM papers WHERE doi = ?", (doi,)).fetchone()
            if same_doi:
                title_key = same_doi["title_key"]
        conn.execute("""
          INSERT INTO papers(title,title_key,authors,conference,year,abstract,keywords,doi,paper_url,pdf_url,source,updated_at)
          VALUES(?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
          ON CONFLICT(title_key) DO UPDATE SET
            authors=CASE WHEN excluded.authors<>'' THEN excluded.authors ELSE papers.authors END,
            conference=CASE WHEN excluded.conference<>'OTHER' THEN excluded.conference ELSE papers.conference END,
            year=COALESCE(excluded.year,papers.year),
            abstract=CASE WHEN excluded.abstract<>'' THEN excluded.abstract ELSE papers.abstract END,
            keywords=CASE WHEN excluded.keywords<>'[]' THEN excluded.keywords ELSE papers.keywords END,
            doi=CASE WHEN excluded.doi<>'' THEN excluded.doi ELSE papers.doi END,
            paper_url=CASE WHEN excluded.paper_url<>'' THEN excluded.paper_url ELSE papers.paper_url END,
            pdf_url=CASE WHEN excluded.pdf_url<>'' THEN excluded.pdf_url ELSE papers.pdf_url END,
            source=CASE WHEN excluded.source<>'' THEN excluded.source ELSE papers.source END,
            updated_at=CURRENT_TIMESTAMP
        """, (title,title_key,paper.get("authors",""),paper.get("conference","OTHER"),paper.get("year"),paper.get("abstract",""),json.dumps(list(dict.fromkeys(keywords)),ensure_ascii=False),doi,paper.get("paper_url",paper.get("url","")),paper.get("pdf_url",""),paper.get("source","manual")))
        return row_to_dict(conn.execute("SELECT * FROM papers WHERE title_key=?", (title_key,)).fetchone())

9.1.2 多源数据适配与错误包装

backend/sources.py:5-33

设计思路:fetch_json 集中设置 User-Agent、超时和异常包装;OpenAlex 的倒排摘要、来源会议名、作者、关键词/主题、链接统一整理成项目字段;DBLP 作为更轻量的书目 fallback。

为什么这么写:上层 main.py 只处理统一论文结构和 SourceError,无需感知每个来源的响应层级。

边界处理:空倒排索引返回空摘要;会议识别不到时为 OTHER;OpenAlex per-page 被钳制到 25;缺失数组和嵌套对象均使用空值回退。DBLP 返回的作者形态没有进一步统一,代码能否覆盖所有 DBLP 响应形态无法从静态代码证实。

class SourceError(RuntimeError): pass

def fetch_json(url,timeout=15):
    req=urllib.request.Request(url,headers={"User-Agent":"VisionPulse/1.0 academic-course-project"})
    try:
        with urllib.request.urlopen(req,timeout=timeout) as response: return json.loads(response.read().decode("utf-8"))
    except Exception as exc: raise SourceError(f"请求失败:{urllib.parse.urlparse(url).netloc},{exc}") from exc

def abstract_from_inverted(index):
    return " ".join(word for _,word in sorted((pos,word) for word,positions in (index or {}).items() for pos in positions))

def infer_conference(source_name,title=""):
    text=f"{source_name} {title}".lower()
    if "cvpr" in text or "computer vision and pattern recognition" in text: return "CVPR"
    if "iccv" in text or "international conference on computer vision" in text: return "ICCV"
    if "eccv" in text or "european conference on computer vision" in text: return "ECCV"
    return "OTHER"

def normalize_openalex(work):
    location=work.get("primary_location") or {}; source=location.get("source") or {}; keywords=[x.get("display_name") or x.get("keyword") for x in work.get("keywords") or []]; topics=[x.get("display_name") for x in work.get("topics") or []]
    return {"title":work.get("display_name") or work.get("title") or "Untitled","authors":", ".join((x.get("author") or {}).get("display_name","") for x in work.get("authorships") or [] if (x.get("author") or {}).get("display_name")),"conference":infer_conference(source.get("display_name", ""),work.get("display_name", "")),"year":work.get("publication_year"),"abstract":abstract_from_inverted(work.get("abstract_inverted_index")),"keywords":list(dict.fromkeys([x for x in keywords+topics if x]))[:12],"doi":work.get("doi") or "","paper_url":location.get("landing_page_url") or work.get("doi") or work.get("id", ""),"pdf_url":location.get("pdf_url") or "","source":"OpenAlex"}

def openalex_search(query,count=10):
    params=urllib.parse.urlencode({"search":query,"per-page":min(count,25),"select":"id,doi,display_name,publication_year,abstract_inverted_index,keywords,topics,primary_location,authorships"})
    return [normalize_openalex(x) for x in fetch_json(f"https://api.openalex.org/works?{params}").get("results",[])]

def dblp_search(query,count=5):
    params=urllib.parse.urlencode({"q":query,"h":count,"format":"json"}); hits=fetch_json(f"https://dblp.org/search/publ/api?{params}").get("result",{}).get("hits",{}).get("hit",[])
    return [{"title":x.get("info",{}).get("title",""),"year":x.get("info",{}).get("year"),"authors":x.get("info",{}).get("authors",{}).get("author",""),"conference":infer_conference(x.get("info",{}).get("venue","")),"paper_url":x.get("info",{}).get("ee",""),"source":"DBLP","keywords":[]} for x in hits]
backend/sources.py:35-43

设计思路:CVPR/ICCV 会议页使用 CVF Open Access HTML 适配器,正则提取标题与详情链接,再补齐会议、年份、来源字段。

边界处理:网络错误包装为带会议年份的 SourceError;页面可访问但结构变化导致零匹配时也显式失败,不把空结果误当成功;按 limit 截断。

def cvf_list(conference,year,limit=100):
    url=f"https://openaccess.thecvf.com/{conference}{year}?day=all"; req=urllib.request.Request(url,headers={"User-Agent":"VisionPulse/1.0 academic-course-project"})
    try:
        with urllib.request.urlopen(req,timeout=25) as response: html=response.read().decode("utf-8","ignore")
    except Exception as exc: raise SourceError(f"CVF {conference}{year} 获取失败:{exc}") from exc
    pattern=re.compile(r'<dt class="ptitle">\s*<br\s*/?>\s*<a href="([^"]+)">\s*(.*?)\s*</a>',re.I|re.S); papers=[]
    for href,raw in pattern.findall(html)[:limit]:
        title=re.sub(r"<[^>]+>","",raw); papers.append({"title":re.sub(r"\s+"," ",title).strip(),"conference":conference,"year":year,"paper_url":urllib.parse.urljoin(url,href),"source":"CVF Open Access","keywords":[]})
    if not papers: raise SourceError(f"CVF {conference}{year} 页面可访问,但未匹配到论文条目")
backend/sources.py:46-59

设计思路:ECCV 单独适配 ECVA 页面,先锁定年份注释分区,再解析题名、作者、详情和 PDF。

边界处理:年份分区不存在、网络失败、正则零命中分别给出清晰错误;相对 URL 用 urljoin 转为绝对地址。

def ecva_list(year,limit=100):
    url="https://www.ecva.net/papers.php"; req=urllib.request.Request(url,headers={"User-Agent":"VisionPulse/1.0 academic-course-project"})
    try:
        with urllib.request.urlopen(req,timeout=25) as response: html=response.read().decode("utf-8","ignore")
    except Exception as exc: raise SourceError(f"ECVA papers 页面获取失败:{exc}") from exc
    block=re.search(rf"<!--\s*ECCV\s+{year}\s*-->(.*?)(?=<!--\s*ECCV\s+\d+\s*-->|\Z)",html,re.I|re.S)
    if not block: raise SourceError(f"ECVA 页面未找到 ECCV {year} 分区")
    pattern=re.compile(r'<dt class="ptitle">\s*<br\s*>\s*<a href=([^ >]+)>\s*(.*?)\s*</a>\s*</dt>\s*<dd>\s*(.*?)\s*</dd>\s*<dd>.*?<a href=[\'\"]([^\'\"]+\.pdf)',re.I|re.S)
    papers=[]
    for href,raw_title,authors,pdf in pattern.findall(block.group(1))[:limit]:
        title=re.sub(r"\s+"," ",re.sub(r"<[^>]+>","",raw_title)).strip()
        papers.append({"title":title,"authors":re.sub(r"\s+"," ",authors).strip(),"conference":"ECCV","year":year,"paper_url":urllib.parse.urljoin(url,href),"pdf_url":urllib.parse.urljoin(url,pdf),"source":"ECVA","keywords":[]})
    if not papers: raise SourceError(f"ECVA ECCV {year} 页面可访问,但未匹配到论文条目")
    return papers

9.1.3 关键词统计、方向分类、共现图谱与相似论文

backend/analysis.py:19-42

设计思路:单篇论文先把来源关键词和题名/摘要 token 合并、过滤停用词并去重;因此 keyword_stats 统计的是“包含该词的论文数”。方向分类使用可解释规则词表;图谱只在 Top 关键词集合内创建同篇共现边。

为什么这么写:单篇去重可避免一个词在摘要中重复出现导致论文被过度加权;显式规则便于课程展示与解释;Top N 和最多 100 条边限制前端图复杂度。

边界处理:无论文时方向占比分母至少为 1;图节点大小有上限增量;子串匹配可能产生语义误判,代码没有词形还原或短语边界模型。

def tokens(paper):
    values=[str(v).strip().lower() for v in (paper.get("keywords") or []) if str(v).strip()]
    words=re.findall(r"[a-z][a-z0-9-]{2,}",f"{paper.get('title','')} {paper.get('abstract','')}".lower())
    return list(dict.fromkeys(values+[w for w in words if w not in STOPWORDS]))

def keyword_stats(papers,limit=30):
    counts=Counter(); [counts.update(tokens(p)) for p in papers]
    return [{"name":n,"value":v} for n,v in counts.most_common(limit)]

def directions_for(paper):
    text=f"{paper.get('title','')} {paper.get('abstract','')} {' '.join(paper.get('keywords') or [])}".lower()
    return [name for name,rules in DIRECTIONS.items() if any(rule in text for rule in rules)]

def top_directions(papers,limit=10):
    counts=Counter(); [counts.update(directions_for(p)) for p in papers]; total=max(len(papers),1)
    return [{"name":n,"value":v,"share":round(v/total,4)} for n,v in counts.most_common(limit)]

def graph(papers,limit=30):
    stats=keyword_stats(papers,limit); names={x["name"] for x in stats}; edges=Counter()
    for p in papers:
        keys=sorted(set(tokens(p))&names)
        for i,left in enumerate(keys):
            for right in keys[i+1:]: edges[(left,right)]+=1
    return {"nodes":[{"name":x["name"],"value":x["value"],"symbolSize":20+min(x["value"],20)*2} for x in stats],"links":[{"source":a,"target":b,"value":v} for (a,b),v in edges.most_common(100)]}
backend/analysis.py:60-66

设计思路:相似论文采用 token 集合的 Jaccard 相似度,并把共同 token 作为可解释理由返回。

边界处理:跳过目标自身;并集为空时分母至少为 1;只保留正相似度;理由最多 8 个,结果按分数降序截断。

def similar_papers(target,papers,limit=5):
    target_keys=set(tokens(target)); scored=[]
    for p in papers:
        if p.get("id")==target.get("id"): continue
        common=sorted(target_keys&set(tokens(p))); score=len(common)/max(len(target_keys|set(tokens(p))),1)
        if score: scored.append({**p,"similarity":round(score,3),"reasons":common[:8]})
    return sorted(scored,key=lambda x:x["similarity"],reverse=True)[:limit]

9.1.4 FastAPI 关键路由与参数校验

backend/main.py:19-37

设计思路:Pydantic 模型统一约束写入字段;查询路由用 FastAPI Query 限制长度、枚举正则、年份和分页范围,再把过滤与分页下推到数据库层。

边界处理:会议和来源还做显式白名单验证并返回 400;总页数即使零记录也返回至少 1。模型中的 keywords:list[str]=[] 是字面默认值,Pydantic 如何复制该默认值依赖其模型实现。

class PaperInput(BaseModel):
    title:str=Field(min_length=1,max_length=500); authors:str=""; conference:str="OTHER"; year:int|None=Field(default=None,ge=1900,le=2100); abstract:str=""; keywords:list[str]=[]; doi:str=""; paper_url:str=""; pdf_url:str=""; source:str="manual"

    @model_validator(mode="after")
    def validate_values(self):
        values=self.model_dump()
        if values.get("conference") not in CONFERENCES: raise ValueError("conference 必须是 CVPR、ICCV、ECCV 或 OTHER")
        if values.get("source") not in SOURCES: raise ValueError("source 不是受支持的数据来源")
        return self

def all_papers(): return db.list_papers()
@app.get("/api/health")
def health(): return {"status":"ok","database":str(db.DB_PATH),"paperCount":len(all_papers())}
@app.get("/api/papers")
def papers(q: str = Query("", max_length=200), mode: str = Query("fuzzy", pattern="^(fuzzy|exact)$"), conference: str = Query(""), year: int|None = Query(None, ge=1900, le=2100), source: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
    if conference and conference not in CONFERENCES: raise HTTPException(400,"conference 必须是 CVPR、ICCV、ECCV 或 OTHER")
    if source and source not in SOURCES: raise HTTPException(400,"source 不是受支持的数据来源")
    total=db.count_papers(q,mode,conference,year,source); rows=db.list_papers(q,mode,conference,year,source,page,page_size)
    return {"count":total,"page":page,"pageSize":page_size,"totalPages":max(1,(total+page_size-1)//page_size),"items":rows}
backend/main.py:57-70

设计思路:创建和更新复用同一 upsert;删除与相似推荐均先处理不存在资源;相似度计算留在分析层。

边界处理:更新前检查路径 ID 是否存在,但写入时 upsert_paper 仍按正文题名/DOI 决定目标,路径 ID 本身没有传给 upsert;因此“路径 ID 对应行一定被更新”无法从代码证实。

@app.post("/api/papers")
def create_paper(paper:PaperInput): return db.upsert_paper(paper.model_dump())
@app.put("/api/papers/{paper_id}")
def update_paper(paper_id:int,paper:PaperInput):
    if not db.get_paper(paper_id): raise HTTPException(404,"论文不存在")
    return db.upsert_paper(paper.model_dump())
@app.delete("/api/papers/{paper_id}")
def delete_paper(paper_id:int):
    if not db.delete_paper(paper_id): raise HTTPException(404,"论文不存在")
    return {"deleted":True}
@app.get("/api/papers/{paper_id}/similar")
def similar(paper_id:int,limit:int=Query(5,ge=1,le=10)):
    target=db.get_paper(paper_id)
    if not target: raise HTTPException(404,"论文不存在")
backend/main.py:91-108

设计思路:单篇搜索按 OpenAlex → DBLP 顺序 fallback;会议采集按会议路由到 CVF 或 ECVA。成功数据立即 upsert 到 SQLite,会议采集同时写 ingest job。

边界处理:搜索 query 最短 2、limit 1..10;会议白名单和 limit 1..300;来源错误累计后以 502 返回。会议 year 未设置范围约束。单篇搜索只在所有来源失败时记录失败 job,成功搜索没有创建 job。

@app.post("/api/ingest/search")
def ingest_search(query:str=Query(min_length=2),limit:int=Query(5,ge=1,le=10)):
    attempts=[]
    for name,fetch in (("OpenAlex",lambda:sources.openalex_search(query,limit)),("DBLP",lambda:sources.dblp_search(query,limit))):
        try:
            items=fetch(); stored=[db.upsert_paper(x) for x in items]; return {"source":name,"items":stored,"notice":f"已通过 {name} 获取并缓存 {len(stored)} 条"}
        except sources.SourceError as exc: attempts.append(str(exc))
    db.create_job("search",query,"OpenAlex → DBLP","failed",message=";".join(attempts)); raise HTTPException(502,"所有数据源均失败:"+";".join(attempts))
@app.post("/api/ingest/conference")
def ingest_conference(conference:str,year:int,limit:int=Query(100,ge=1,le=300)):
    if conference not in {"CVPR","ICCV","ECCV"}: raise HTTPException(400,"会议采集适配器支持 CVPR / ICCV / ECCV")
    try:
        source_name="ECVA" if conference=="ECCV" else "CVF Open Access"
        items=sources.ecva_list(year,limit) if conference=="ECCV" else sources.cvf_list(conference,year,limit)
        stored=[db.upsert_paper(x) for x in items]; job=db.create_job("conference",f"{conference}{year}",source_name,"success",len(items),len(stored)); return {"jobId":job,"source":source_name,"items":stored,"count":len(stored)}
    except sources.SourceError as exc:
        source_name="ECVA" if conference=="ECCV" else "CVF Open Access"
        job=db.create_job("conference",f"{conference}{year}",source_name,"failed",message=str(exc)); raise HTTPException(502,f"会议采集失败(job {job}):{exc}") from exc

9.1.5 前端 OpenAlex 规范化、保存、批量导入与图表渲染

app.js:281-319 — reconstructAbstract / normalizeOpenAlex

设计思路:浏览器离线于后端时也能直接消费 OpenAlex;摘要按位置复原,关键词与 topics 合并去重,会议由来源名判断,最后统一成页面论文对象。

边界处理:空摘要索引返回空串;无 ID 时生成本地 ID;无年份回退 2026;无关键词回退 Computer Vision;链接按 DOI、落地页、OpenAlex ID 顺序回退。

    function reconstructAbstract(inv) {
      if (!inv) return '';
      const pairs=[];
      Object.entries(inv).forEach(([word,positions]) =>
        (positions||[]).forEach(pos=>pairs.push([pos,word]))
      );
      pairs.sort((a,b)=>a[0]-b[0]);
      return pairs.map(x=>x[1]).join(' ');
    }

    function normalizeOpenAlex(w) {
      const kws = [];
      (w.keywords || []).forEach(k => {
        const name = k.display_name || k.keyword;
        if (name && !kws.includes(name)) kws.push(name);
      });
      (w.topics || []).slice(0,5).forEach(t => {
        const name = t.display_name;
        if (name && !kws.includes(name)) kws.push(name);
      });
      const sourceName = w.primary_location?.source?.display_name || '';
      let conf='OTHER';
      if (/CVPR|Computer Vision and Pattern Recognition/i.test(sourceName)) conf='CVPR';
      else if (/ICCV|International Conference on Computer Vision/i.test(sourceName)) conf='ICCV';
      else if (/ECCV|European Conference on Computer Vision/i.test(sourceName)) conf='ECCV';
      return {
        id: String(w.id || '').split('/').pop() || nextId(),
        title: w.display_name || w.title || 'Untitled',
        conference: conf,
        year: w.publication_year || 2026,
        keywords: kws.length ? kws.slice(0,8) : ['Computer Vision'],
        abstract: reconstructAbstract(w.abstract_inverted_index),
        url: w.doi || w.primary_location?.landing_page_url || w.id || 'https://openalex.org/'
      };
    }

    async function openAlexSearch(query, count=5) {
      const params = new URLSearchParams({
        search: query,
app.js:342-373 — saveRemote / batchImport

设计思路:远端结果先按题名大小写不敏感去重,再加入响应式数组并持久化;批量输入逐行串行请求 OpenAlex,以降低接口压力。

边界处理:空文件/空输入提前返回;批量单次最多处理 10 行;单条失败被捕获后继续;只有真正新增才累计成功数。直接前端保存只按题名去重,没有 DOI 去重。

    function saveRemote(r, silent = false) {
      const titleLower=r.title.toLowerCase();
      if (papers.value.some(p=>p.title.toLowerCase()===titleLower)) return false;
      papers.value.unshift({...r,id:nextId()});
      persist();
      if (!silent) notify('已加入论文列表');
      nextTick(renderDashboard);
      return true;
    }

    function readFile(e) {
      const file=e.target.files?.[0];
      if (!file) return;
      const reader=new FileReader();
      reader.onload=()=>{ batchText.value=String(reader.result||''); };
      reader.readAsText(file,'utf-8');
    }

    async function batchImport() {
      const lines=batchText.value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);
      if (!lines.length) return notify('请先输入或选择文件');
      batchLoading.value=true; let ok=0;
      for (let i=0;i<Math.min(lines.length,10);i++) {
        batchProgress.value=`${i+1}/${Math.min(lines.length,10)}`;
        try {
          const rs=await openAlexSearch(lines[i],1);
          if (rs[0] && saveRemote(rs[0], true)) ok++;
        } catch(e) {}
      }
      batchLoading.value=false; batchProgress.value='';
      batchMessage.value=`批量处理完成:成功加入 ${ok} 篇(为避免接口压力,单次最多处理10条)。`;
    }
app.js:384-428 — Top10 与关键词图谱

设计思路:renderDashboard 复用 ECharts 实例,通过 setOption 更新;Top10 使用横向柱图,共现关系使用 force graph;节点点击写入 Vue 的 selectedKeyword,驱动相关论文计算属性。

边界处理:非 dashboard 页面或 ECharts 未加载时直接返回;DOM 不存在时跳过;图边做无向去重;点击监听先 off 再 on,避免重复绑定。

      if (page.value!=='dashboard' || typeof echarts==='undefined') return;
      const top = keywordStats.value.slice(0,10).reverse();
      const topEl=document.getElementById('topChart');
      if (topEl) {
        topChart = topChart || echarts.init(topEl);
        topChart.setOption({
          animationDuration:700,
          grid:{left:120,right:30,top:20,bottom:30},
          xAxis:{type:'value',minInterval:1,splitLine:{lineStyle:{color:'#eef1f5'}}},
          yAxis:{type:'category',data:top.map(x=>x.name),axisLabel:{color:'#596477'}},
          series:[{type:'bar',data:top.map(x=>x.value),barWidth:16,itemStyle:{borderRadius:[0,6,6,0]},label:{show:true,position:'right'}}],
          tooltip:{trigger:'axis'}
        });
      }

      const graphEl=document.getElementById('graphChart');
      if (graphEl) {
        graphChart = graphChart || echarts.init(graphEl);
        const nodes=keywordStats.value.slice(0,18).map((x,i)=>({
          name:x.name,value:x.value,symbolSize:28+x.value*6,
          category:i<5?0:(i<11?1:2)
        }));
        const names=new Set(nodes.map(n=>n.name));
        const links=[];
        papers.value.forEach(p=>{
          const ks=analysisKeywords(p).filter(k=>names.has(k));
          for(let i=0;i<ks.length;i++) for(let j=i+1;j<ks.length;j++) {
            if(!links.some(l=>(l.source===ks[i]&&l.target===ks[j])||(l.source===ks[j]&&l.target===ks[i])))
              links.push({source:ks[i],target:ks[j]});
          }
        });
        graphChart.setOption({
          tooltip:{},
          legend:[{data:['核心热点','重要方向','关联方向'],bottom:0}],
          series:[{type:'graph',layout:'force',roam:true,data:nodes,links,
            categories:[{name:'核心热点'},{name:'重要方向'},{name:'关联方向'}],
            label:{show:true,fontSize:11},force:{repulsion:260,edgeLength:[50,120]},
            lineStyle:{opacity:.35,curveness:.08}}]
        });
        graphChart.off('click');
        graphChart.on('click', params=>{
          if(params.dataType==='node'){ selectedKeyword.value=params.name; }
        });
      }
    }
app.js:459-512 — 热度走势动画

设计思路:初始化或复用趋势图实例;每个年度用三组会议柱状序列展示,setInterval 每 1600 ms 切换年份,setOption(..., true) 做整组选项替换并触发动画。

边界处理:非趋势页、ECharts 缺失、容器缺失或实例未初始化时返回;重建定时器前总是 clearInterval;暂停状态不新建定时器;重播从首年开始。若快照年份数组为空,前置 getTrendSnapshot 会提供安全年份,因此取模分母不为零。

      if (page.value!=='trend' || typeof echarts==='undefined') return;
      const el=document.getElementById('trendChart');
      if(!el) return;
      trendChart = trendChart || echarts.init(el);
      const snapshot = getTrendSnapshot();
      trendIndex = Math.min(trendIndex, snapshot.years.length - 1);
      showTrendYear(snapshot.years[trendIndex], snapshot);
      startTrendTimer();
    }

    function showTrendYear(year, snapshot=getTrendSnapshot()) {
      if(!trendChart) return;
      const d=snapshot.data[year];
      trendChart.setOption({
        title:{
          text:`${year} 年热词热度对比`,
          subtext:'统计口径:当前论文列表中,“年份 × 顶会 × 关键词”的出现次数'
        },
        tooltip:{trigger:'axis'},
        legend:{data:['CVPR','ICCV','ECCV'],top:42},
        grid:{left:55,right:30,top:95,bottom:80},
        xAxis:{type:'category',data:snapshot.keywords,axisLabel:{rotate:18}},
        yAxis:{type:'value',minInterval:1,name:'论文数'},
        series:['CVPR','ICCV','ECCV'].map(name=>({
          name,
          type:'bar',
          data:d[name],
          barMaxWidth:34,
          animationDuration:900,
          animationEasing:'cubicOut'
        }))
      },true);
    }

    function startTrendTimer() {
      clearInterval(trendTimer);
      if(!trendPlaying.value) return;
      trendTimer=setInterval(()=>{
        const snapshot=getTrendSnapshot();
        trendIndex=(trendIndex+1)%snapshot.years.length;
        showTrendYear(snapshot.years[trendIndex], snapshot);
      },1600);
    }

    function toggleTrend() {
      trendPlaying.value=!trendPlaying.value;
      startTrendTimer();
    }
    function restartTrend() {
      const snapshot=getTrendSnapshot();
      trendIndex=0;
      trendPlaying.value=true;
      showTrendYear(snapshot.years[0], snapshot);
      startTrendTimer();

9.2 可解释性声明

AI 参与了方案初稿、样板代码和调试建议;最终接口调用、数据结构、统计口径、边界处理、部署脚本与本文档事实性内容由本人审查、运行验证并确认。上面的代码片段均标出真实文件与行号,我能够解释其输入、输出、错误分支和已知边界,不提交未理解的 AI 产出。


10. 提交前全项自检

状态只使用 PASS / PARTIAL / MISSING。PASS 必须有正文内或仓库内的真实证据;附件中存在但未进入博客正文的内容不计为 PASS。

统计:17 项 PASS,3 项 PARTIAL,3 项 MISSING。


11. 心路历程、收获与反思

11.1 人机结对 vs 传统人人结对

维度传统人人结对人机结对(本次实践)
反馈速度受对方时间与状态限制,但即时而双向几乎即时,但单向——AI 不会主动质疑我的前提
质疑设计结对伙伴会实时挑战你的判断必须由我主动扮演审查者,否则 AI 会顺着我的错误前提往下写
上下文共享同一份现实(能一起看屏幕、一起跑命令)AI 不知道本机状态、真实返回与外部平台权限,需要我把事实喂准
知识边界两人知识互补AI 有幻觉与知识截止,且会自信地给出错误结论
代价沟通等待成本验证成本:AI 产出必须逐条运行验证才能用
产出速度慢但稳从空白到可运行原型极快,但质量取决于提问与验收质量

结论:人机结对把"写"的成本大幅降低,把"验证"的责任全部压回给开发者。所以本次我给自己定的规则是:任何数字都要跑命令;任何"AI 说没问题"都要自己复现一遍。

11.2 三个具体教训

  1. AI 会构建"看起来对"的东西,而验证是开发者的活。案例 B 里 AI 的 upsert 初稿用直接覆盖,功能测试也能过——只有当我构造"第二次抓到的记录字段更少"的场景时,才会发现旧数据被冲空。测试用例要覆盖失败与降级路径,而不是只覆盖成功路径。

  2. "TCP 能连上"不等于"服务在跑"。排查 SSH 时我一度凭端口握手就宣布安全组已放行,后来发现云转发层会无差别接受 TCP——80/443 上根本没有服务也"握手成功"。结论必须建立在应用层证据(banner / HTTP 200)之上。 这是我这次最想记住的一条。

  3. 宁可误停,不可漏放。数据库同步脚本因 bytes/str 类型比较误判 SHA256 不一致而中止——那恰恰是保护机制正确工作的样子。如果我当时为了赶时间删掉校验,就会失去唯一能防止"传错文件覆盖线上库"的防线。

11.3 对 AI 的评价

优势:拆解需求、列举方案与竞品视角、生成样板代码与适配层骨架、解释报错、写测试用例,效率远超手工。它能在我"知道要做什么但懒得展开"的地方迅速铺开细节。

局限(本次真实遇到):

  • 不知道自己不知道:AI 不会因为缺少本机信息而停下来,而是继续自信地给方案——所以必须由我提供准确上下文。
  • 不掌握外部平台现状:华为云控制台的实际交互、安全组默认规则、Flexus L 的登录方式,AI 的"常见做法"未必等于这套控制台的真实行为,必须实测。
  • 幻觉风险:本交付包始终坚持"零臆造"——凡是没实测到的一律标 PARTIAL/MISSING,这条纪律就是专门用来对抗 AI(和我自己)倾向于"补齐一个看似合理的答案"的。

我的判断:AI 显著提高了从空白到可运行版本的速度,但最终责任完全属于提交者。把需求、约束和验证标准说清楚,然后逐条验收——这是人机结对唯一有效的工作方式。

11.4 如果重做一次

  1. 更早把"部署"提到前面:公网链路(云账号、安全组、登录方式)的不确定性远大于写代码,应该第一天就把"能连上"打通。
  2. 数据管线要设计"可离线复现":这次云端抓取不全(ICCV 404),最后靠迁移本地已验证数据库解决。更好的做法是让采集结果可导出/可导入成为一等功能。
  3. 给每个评分点留证据截图位:证明"我做过了"的成本,比"做"更高。

附录:本文档事实性内容的复现命令

# 本地仓库统计
git -C <repo> rev-list --count main
git -C <repo> rev-list --no-merges --count main
git -C <repo> log --merges --oneline main
git -C <repo> for-each-ref --format="%(refname:short) %(objectname:short)"

# 远端一致性(独立 mirror clone + 匿名读)
git ls-remote --heads --tags <codearts-url>
git clone --mirror <codearts-url> /tmp/verify.git
git -C /tmp/verify.git rev-list --count main

# 数据库统计
python -c "import sqlite3; c=sqlite3.connect('data/visionpulse.sqlite3'); print(c.execute('select count(*) from papers').fetchone())"

# 后端测试与构建检查
python -m pytest -p no:cacheprovider backend/tests -q
npm run build

# 公网验证
curl -fsS http://119.3.251.115/api/health
ssh-keyscan -T 10 119.3.251.115
...全文
132 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

88

社区成员

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

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